From 71e5bcb9477c9d626f162dc55e0218e27edaebce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:02:05 +0200 Subject: [PATCH 01/36] [PM-11885] [deps] Tools: Update electron to v32.0.2 (#10964) * [deps] Tools: Update electron to v32.0.2 * Bump version electron-builder.json --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- apps/desktop/electron-builder.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index b6572587fa..49e414dfe9 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -25,7 +25,7 @@ "**/node_modules/argon2/package.json", "**/node_modules/argon2/build/Release/argon2.node" ], - "electronVersion": "32.0.1", + "electronVersion": "32.0.2", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/package-lock.json b/package-lock.json index 055962ac49..0a9936bbf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,7 +129,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.0.1", + "electron": "32.0.2", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", @@ -16433,9 +16433,9 @@ } }, "node_modules/electron": { - "version": "32.0.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.1.tgz", - "integrity": "sha512-5Hd5Jaf9niYVR2hZxoRd3gOrcxPOxQV1XPV5WaoSfT9jLJHFadhlKtuSDIk3U6rQZke+aC7GqPPAv55nWFCMsA==", + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz", + "integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 1ed2e6cab9..9e1921cbdf 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.0.1", + "electron": "32.0.2", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", From c1b1db071d9def6a293fb6860a1c664d737fed74 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:57:47 +0100 Subject: [PATCH 02/36] [AC-3029][Defect] Organization storage count is not carried over to the Upgrade dialog Total section (#10965) * Resolve the ui changes issues * Resolve the storage ui issue --- .../change-plan-dialog.component.html | 24 ++++++++++++------- .../change-plan-dialog.component.ts | 21 ++++++++++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 02071f5aa8..1c92cbf663 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -389,17 +389,21 @@ bitTypography="body2" *ngIf=" selectedPlan.PasswordManager.hasAdditionalStorageOption && - !organization.useSecretsManager + !organization.useSecretsManager && + organization.maxStorageGb > 0 " > - {{ 0 }} + {{ organization.maxStorageGb }} {{ "additionalStorageGbMessage" | i18n }} × - {{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} + {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} /{{ "year" | i18n }} - {{ 0 | currency: "$" }} + {{ + organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb + | currency: "$" + }}

@@ -505,17 +509,21 @@ bitTypography="body2" *ngIf=" selectedPlan.PasswordManager.hasAdditionalStorageOption && - !organization.useSecretsManager + !organization.useSecretsManager && + organization.maxStorageGb > 0 " > - {{ 0 }} + {{ organization.maxStorageGb }} {{ "additionalStorageGbMessage" | i18n }} × - {{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} + {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} /{{ "month" | i18n }} - {{ 0 | currency: "$" }} + {{ + organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb + | currency: "$" + }}

diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index a359c281eb..9492b51fcb 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -477,6 +477,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } + additionalStoragePriceMonthly(selectedPlan: PlanResponse) { + if (!selectedPlan.isAnnual) { + return selectedPlan.PasswordManager.additionalStoragePricePerGb; + } + return selectedPlan.PasswordManager.additionalStoragePricePerGb / 12; + } + additionalServiceAccountTotal(plan: PlanResponse): number { if (!plan.SecretsManager.hasAdditionalServiceAccountOption || this.additionalServiceAccount) { return 0; @@ -525,9 +532,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { get total() { if (this.organization.useSecretsManager) { - return this.passwordManagerSubtotal + this.secretsManagerSubtotal + this.taxCharges || 0; + return ( + this.passwordManagerSubtotal + + this.additionalStorageTotal(this.selectedPlan) + + this.secretsManagerSubtotal + + this.taxCharges || 0 + ); } - return this.passwordManagerSubtotal + this.taxCharges || 0; + return ( + this.passwordManagerSubtotal + + this.additionalStorageTotal(this.selectedPlan) + + this.taxCharges || 0 + ); } get teamsStarterPlanIsAvailable() { @@ -639,6 +655,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (this.selectedPlan.productTier !== ProductTierType.Families) { request.additionalSeats = this.organization.seats; } + request.additionalStorageGb = this.organization.maxStorageGb; request.premiumAccessAddon = this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; From 2d02b6ca5ce1e7d41d2e2acc89e44cc6aa75207d Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:29:48 -0400 Subject: [PATCH 03/36] Auth/PM-11252 - Registration with Email Verification - Add new redirect connector (#10682) * PM-11252 - Registration with email verification - Add new signup redirect connector * PM-11252 - Make the redirect connector generic and extensible while updating it to reference the new fragment based approach which prevents open redirects and prevents the query string from being sent to servers or proxies. * PM-11252 - PR feedback - refactor redirect to simply forward any fragment onward with no query param parsing required leading to an even more generic solution. * PM-11252 - Docs * PM-11252 - PR Feedback - Include styles in chunks to remove need to manually import scss * PM-11252 - Update redirect html to tailwind. --- apps/web/src/connectors/redirect.html | 29 +++++++++++++++++++++++++++ apps/web/src/connectors/redirect.ts | 17 ++++++++++++++++ apps/web/webpack.config.js | 6 ++++++ 3 files changed, 52 insertions(+) create mode 100644 apps/web/src/connectors/redirect.html create mode 100644 apps/web/src/connectors/redirect.ts diff --git a/apps/web/src/connectors/redirect.html b/apps/web/src/connectors/redirect.html new file mode 100644 index 0000000000..13b05fb17e --- /dev/null +++ b/apps/web/src/connectors/redirect.html @@ -0,0 +1,29 @@ + + + + + + + + Bitwarden Web vault + + + + + + + + + +

+ Bitwarden +
+ +
+
+ + diff --git a/apps/web/src/connectors/redirect.ts b/apps/web/src/connectors/redirect.ts new file mode 100644 index 0000000000..82bd273fad --- /dev/null +++ b/apps/web/src/connectors/redirect.ts @@ -0,0 +1,17 @@ +// This redirect connector is used to redirect users to the correct URL after they have been sent here from an email link. +// The fragment contains the information needed to redirect the user to the correct page. +// This is required because android app links couldn't properly handle the angular hash based route we originally had in the email link. +window.addEventListener("load", () => { + // ex: https://vault.bitwarden.com/redirect-connector.html#finish-signup?token=fakeToken&email=example%40example.com&fromEmail=true + const currentUrl = new URL(window.location.href); + + // Get the fragment (everything after the #) + const fragment = currentUrl.hash.substring(1); // Remove the leading # + + if (!fragment) { + throw new Error("No fragment found in URL. Cannot determine redirect."); + } + + const newUrl = `${window.location.origin}/#/${fragment}`; + window.location.href = newUrl; +}); diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index ce3979f791..cec4bf044b 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -111,6 +111,11 @@ const plugins = [ filename: "sso-connector.html", chunks: ["connectors/sso"], }), + new HtmlWebpackPlugin({ + template: "./src/connectors/redirect.html", + filename: "redirect-connector.html", + chunks: ["connectors/redirect", "styles"], + }), new HtmlWebpackPlugin({ template: "./src/connectors/captcha.html", filename: "captcha-connector.html", @@ -325,6 +330,7 @@ const webpackConfig = { "connectors/sso": "./src/connectors/sso.ts", "connectors/captcha": "./src/connectors/captcha.ts", "connectors/duo-redirect": "./src/connectors/duo-redirect.ts", + "connectors/redirect": "./src/connectors/redirect.ts", styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"], theme_head: "./src/theme.ts", }, From 4128b18b27ed8f183f7787514630cc63185f3a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 10 Sep 2024 12:43:33 -0400 Subject: [PATCH 04/36] [PM-8279] password generation component (#10805) --- .../credential-generator.component.html | 2 +- .../credential-generator.component.ts | 7 +- libs/common/src/tools/dependencies.ts | 2 +- .../integration/rpc/integration-request.ts | 11 +- libs/common/src/tools/types.ts | 17 + .../generator/components/src/dependencies.ts | 37 +- libs/tools/generator/components/src/index.ts | 1 + .../src/password-generator.component.html | 44 ++ .../src/password-generator.component.ts | 117 +++++ .../generator/core/src/data/generators.ts | 15 + .../src/engine/password-randomizer.spec.ts | 36 ++ .../core/src/engine/password-randomizer.ts | 52 ++- .../credential-generator.service.spec.ts | 297 ++++++++++++- .../services/credential-generator.service.ts | 60 ++- .../passphrase-generator-strategy.spec.ts | 2 +- .../passphrase-generator-strategy.ts | 12 +- .../strategies/password-generator-strategy.ts | 59 +-- .../core/src/types/credential-category.ts | 5 + .../credential-generator-configuration.ts | 20 + .../core/src/types/credential-generator.ts | 12 + .../src/types/generated-credential.spec.ts | 58 +++ .../core/src/types/generated-credential.ts | 47 ++ libs/tools/generator/core/src/types/index.ts | 3 + libs/tools/generator/core/src/util.spec.ts | 411 +++++++++++++++++- libs/tools/generator/core/src/util.ts | 83 ++++ 25 files changed, 1292 insertions(+), 118 deletions(-) create mode 100644 libs/tools/generator/components/src/password-generator.component.html create mode 100644 libs/tools/generator/components/src/password-generator.component.ts create mode 100644 libs/tools/generator/core/src/types/credential-category.ts create mode 100644 libs/tools/generator/core/src/types/credential-generator.ts create mode 100644 libs/tools/generator/core/src/types/generated-credential.spec.ts create mode 100644 libs/tools/generator/core/src/types/generated-credential.ts diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html index 0b43b0e257..d8c49da5b1 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.html +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -1 +1 @@ - + diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index f07affd237..16938fbe79 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -1,14 +1,11 @@ import { Component } from "@angular/core"; -import { - PassphraseSettingsComponent, - PasswordSettingsComponent, -} from "@bitwarden/generator-components"; +import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [PassphraseSettingsComponent, PasswordSettingsComponent], + imports: [PasswordGeneratorComponent], }) export class CredentialGeneratorComponent {} diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 0488291b44..8b860591d5 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -77,7 +77,7 @@ export type SingleUserDependency = { export type OnDependency = { /** The stream that controls emissions */ - on$: Observable; + on$: Observable; }; /** A pattern for types that emit when a dependency is `true`. diff --git a/libs/common/src/tools/integration/rpc/integration-request.ts b/libs/common/src/tools/integration/rpc/integration-request.ts index 84a7b517ab..9f1808a632 100644 --- a/libs/common/src/tools/integration/rpc/integration-request.ts +++ b/libs/common/src/tools/integration/rpc/integration-request.ts @@ -1,11 +1,6 @@ +import { GenerationRequest } from "../../types"; + /** Options that provide contextual information about the application state * when an integration is invoked. */ -export type IntegrationRequest = { - /** @param website The domain of the website the requested integration is used - * within. This should be set to `null` when the request is not specific - * to any website. - * @remarks this field contains sensitive data - */ - website: string | null; -}; +export type IntegrationRequest = Partial; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index 0c2f2832ea..83d69edb06 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -46,3 +46,20 @@ export type Constraints = { /** utility type for methods that evaluate constraints generically. */ export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints; + +/** Options that provide contextual information about the application state + * when a generator is invoked. + */ +export type VaultItemRequest = { + /** The domain of the website the requested credential is used + * within. This should be set to `null` when the request is not specific + * to any website. + * @remarks this field contains sensitive data + */ + website: string | null; +}; + +/** Options that provide contextual information about the application state + * when a generator is invoked. + */ +export type GenerationRequest = Partial; diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/dependencies.ts index 927c3811c8..d96ff0db8d 100644 --- a/libs/tools/generator/components/src/dependencies.ts +++ b/libs/tools/generator/components/src/dependencies.ts @@ -4,41 +4,60 @@ import { ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, CheckboxModule, ColorPasswordModule, FormFieldModule, + IconButtonModule, InputModule, + ItemModule, SectionComponent, SectionHeaderComponent, + ToggleGroupModule, } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; +import { + createRandomizer, + CredentialGeneratorService, + Randomizer, +} from "@bitwarden/generator-core"; + +const RANDOMIZER = new SafeInjectionToken("Randomizer"); /** Shared module containing generator component dependencies */ @NgModule({ - imports: [SectionComponent, SectionHeaderComponent, CardComponent], + imports: [CardComponent, SectionComponent, SectionHeaderComponent], exports: [ + CardComponent, + CheckboxModule, + CommonModule, + ColorPasswordModule, + FormFieldModule, + IconButtonModule, + InputModule, + ItemModule, JslibModule, JslibServicesModule, - FormFieldModule, - CommonModule, ReactiveFormsModule, - ColorPasswordModule, - InputModule, - CheckboxModule, SectionComponent, SectionHeaderComponent, - CardComponent, + ToggleGroupModule, ], providers: [ + safeProvider({ + provide: RANDOMIZER, + useFactory: createRandomizer, + deps: [CryptoService], + }), safeProvider({ provide: CredentialGeneratorService, useClass: CredentialGeneratorService, - deps: [StateProvider, PolicyService], + deps: [RANDOMIZER, StateProvider, PolicyService], }), ], declarations: [], diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 5915c5d59f..4423f8a1ec 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -2,3 +2,4 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { PasswordSettingsComponent } from "./password-settings.component"; +export { PasswordGeneratorComponent } from "./password-generator.component"; diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html new file mode 100644 index 0000000000..db5a1ed379 --- /dev/null +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -0,0 +1,44 @@ + + + {{ "password" | i18n }} + + + {{ "passphrase" | i18n }} + + + +
+ +
+
+ + +
+
+ + diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts new file mode 100644 index 0000000000..6c84d83c4c --- /dev/null +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -0,0 +1,117 @@ +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; +import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core"; +import { GeneratedCredential } from "@bitwarden/generator-history"; + +import { DependenciesModule } from "./dependencies"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; + +/** Options group for passwords */ +@Component({ + standalone: true, + selector: "bit-password-generator", + templateUrl: "password-generator.component.html", + imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent], +}) +export class PasswordGeneratorComponent implements OnInit, OnDestroy { + constructor( + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + private zone: NgZone, + ) {} + + /** Binds the passphrase component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** tracks the currently selected credential type */ + protected credentialType$ = new BehaviorSubject("password"); + + /** Emits the last generated value. */ + protected readonly value$ = new BehaviorSubject(""); + + /** Emits when the userId changes */ + protected readonly userId$ = new BehaviorSubject(null); + + /** Emits when a new credential is requested */ + protected readonly generate$ = new Subject(); + + /** Tracks changes to the selected credential type + * @param type the new credential type + */ + protected onCredentialTypeChanged(type: GeneratorType) { + if (this.credentialType$.value !== type) { + this.credentialType$.next(type); + this.generate$.next(); + } + } + + /** Emits credentials created from a generation request. */ + @Output() + readonly onGenerated = new EventEmitter(); + + async ngOnInit() { + if (this.userId) { + this.userId$.next(this.userId); + } else { + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); + } + + this.credentialType$ + .pipe( + switchMap((type) => this.typeToGenerator$(type)), + takeUntil(this.destroyed), + ) + .subscribe((generated) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.onGenerated.next(generated); + this.value$.next(generated.credential); + }); + }); + } + + private typeToGenerator$(type: GeneratorType) { + const dependencies = { + on$: this.generate$, + userId$: this.userId$, + }; + + switch (type) { + case "password": + return this.generatorService.generate$(Generators.Password, dependencies); + + case "passphrase": + return this.generatorService.generate$(Generators.Passphrase, dependencies); + default: + throw new Error(`Invalid generator type: "${type}"`); + } + } + + private readonly destroyed = new Subject(); + ngOnDestroy(): void { + // tear down subscriptions + this.destroyed.complete(); + + // finalize subjects + this.generate$.complete(); + this.value$.complete(); + + // finalize component bindings + this.onGenerated.complete(); + } +} diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 94e289be03..f71d484f9c 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,5 +1,8 @@ +import { Randomizer } from "../abstractions"; +import { PasswordRandomizer } from "../engine"; import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage"; import { + CredentialGenerator, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, @@ -14,6 +17,12 @@ import { DefaultPasswordGenerationOptions } from "./default-password-generation- import { Policies } from "./policies"; const PASSPHRASE = Object.freeze({ + category: "passphrase", + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new PasswordRandomizer(randomizer); + }, + }, settings: { initial: DefaultPassphraseGenerationOptions, constraints: { @@ -32,6 +41,12 @@ const PASSPHRASE = Object.freeze({ >); const PASSWORD = Object.freeze({ + category: "password", + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new PasswordRandomizer(randomizer); + }, + }, settings: { initial: DefaultPasswordGenerationOptions, constraints: { diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts index bbc31a4a29..fca98855fd 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -335,4 +335,40 @@ describe("PasswordRandomizer", () => { expect(result).toEqual("foo-foo"); }); }); + + describe("generate", () => { + it("processes password generation options", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.generate( + {}, + { + length: 10, + }, + ); + + expect(result.category).toEqual("password"); + }); + + it("processes passphrase generation options", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.generate( + {}, + { + numWords: 10, + }, + ); + + expect(result.category).toEqual("passphrase"); + }); + + it("throws when it cannot recognize the options type", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = password.generate({}, {}); + + await expect(result).rejects.toBeInstanceOf(Error); + }); + }); }); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index 438ea8b8b4..c3a2e2b5d9 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -1,13 +1,26 @@ import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { + CredentialGenerator, + GeneratedCredential, + PassphraseGenerationOptions, + PasswordGenerationOptions, +} from "../types"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest } from "../util"; import { Randomizer } from "./abstractions"; import { Ascii } from "./data"; import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types"; /** Generation algorithms that produce randomized secrets */ -export class PasswordRandomizer { +export class PasswordRandomizer + implements + CredentialGenerator, + CredentialGenerator +{ /** Instantiates the password randomizer - * @param random data source for random data + * @param randomizer data source for random data */ constructor(private randomizer: Randomizer) {} @@ -52,6 +65,41 @@ export class PasswordRandomizer { return wordList.join(request.separator); } + + generate( + request: GenerationRequest, + settings: PasswordGenerationOptions, + ): Promise; + generate( + request: GenerationRequest, + settings: PassphraseGenerationOptions, + ): Promise; + async generate( + _request: GenerationRequest, + settings: PasswordGenerationOptions | PassphraseGenerationOptions, + ) { + if (isPasswordGenerationOptions(settings)) { + const request = optionsToRandomAsciiRequest(settings); + const password = await this.randomAscii(request); + + return new GeneratedCredential(password, "password", Date.now()); + } else if (isPassphraseGenerationOptions(settings)) { + const request = optionsToEffWordListRequest(settings); + const passphrase = await this.randomEffLongWords(request); + + return new GeneratedCredential(passphrase, "passphrase", Date.now()); + } + + throw new Error("Invalid settings received by generator."); + } +} + +function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions { + return "length" in (settings ?? {}); +} + +function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions { + return "numWords" in (settings ?? {}); } // given a generator request, convert each of its `number | undefined` properties diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 31f5134918..5b784b3d07 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -8,9 +8,14 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st import { Constraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; -import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec"; -import { PolicyEvaluator } from "../abstractions"; -import { CredentialGeneratorConfiguration } from "../types"; +import { + FakeStateProvider, + FakeAccountService, + awaitAsync, + ObservableTracker, +} from "../../../../../common/spec"; +import { PolicyEvaluator, Randomizer } from "../abstractions"; +import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types"; import { CredentialGeneratorService } from "./credential-generator.service"; @@ -34,8 +39,23 @@ const somePolicy = new Policy({ enabled: true, }); +const SomeTime = new Date(1); +const SomeCategory = "passphrase"; + // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { + category: SomeCategory, + engine: { + create: (randomizer) => { + return { + generate: (request, settings) => { + const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; + const result = new GeneratedCredential(credential, SomeCategory, SomeTime); + return Promise.resolve(result); + }, + }; + }, + }, settings: { initial: { foo: "initial" }, constraints: { foo: {} }, @@ -87,6 +107,9 @@ const accountService = new FakeAccountService({ // fake state const stateProvider = new FakeStateProvider(accountService); +// fake randomizer +const randomizer = mock(); + describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); @@ -94,10 +117,242 @@ describe("CredentialGeneratorService", () => { jest.clearAllMocks(); }); + describe("generate$", () => { + it("emits a generation for the active user when subscribed", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime)); + }); + + it("follows the active user", async () => { + const someSettings = { foo: "some value" }; + const anotherSettings = { foo: "another value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + await accountService.switchAccount(AnotherUser); + await generated.pauseUntilReceived(2); + generated.unsubscribe(); + + expect(generated.emissions).toEqual([ + new GeneratedCredential("some value", SomeCategory, SomeTime), + new GeneratedCredential("another value", SomeCategory, SomeTime), + ]); + }); + + it("emits a generation when the settings change", async () => { + const someSettings = { foo: "some value" }; + const anotherSettings = { foo: "another value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); + await generated.pauseUntilReceived(2); + generated.unsubscribe(); + + expect(generated.emissions).toEqual([ + new GeneratedCredential("some value", SomeCategory, SomeTime), + new GeneratedCredential("another value", SomeCategory, SomeTime), + ]); + }); + + // FIXME: test these when the fake state provider can create the required emissions + it.todo("errors when the settings error"); + it.todo("completes when the settings complete"); + + it("includes `website$`'s last emitted value", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime)); + }); + + it("errors when `website$` errors", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + let error = null; + + generator.generate$(SomeConfiguration, { website$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + website$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `website$` completes", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + let completed = false; + + generator.generate$(SomeConfiguration, { website$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + website$.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("emits a generation for a specific user when `user$` supplied", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(AnotherUser).asObservable(); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime)); + }); + + it("emits a generation for a specific user when `user$` emits", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.pipe(filter((u) => !!u)); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); + + userId.next(AnotherUser); + const result = await generated.pauseUntilReceived(2); + + expect(result).toEqual([ + new GeneratedCredential("value", SomeCategory, SomeTime), + new GeneratedCredential("another", SomeCategory, SomeTime), + ]); + }); + + it("errors when `user$` errors", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser); + let error = null; + + generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + userId$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `user$` completes", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser); + let completed = false; + + generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + userId$.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("emits a generation only when `on$` emits", async () => { + // This test breaks from arrange/act/assert because it is testing causality + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + const results: any[] = []; + + // confirm no emission during subscription + const sub = generator + .generate$(SomeConfiguration, { on$ }) + .subscribe((result) => results.push(result)); + await awaitAsync(); + expect(results.length).toEqual(0); + + // confirm forwarded emission + on$.next(); + await awaitAsync(); + expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]); + + // confirm updating settings does not cause an emission + await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); + await awaitAsync(); + expect(results.length).toBe(1); + + // confirm forwarded emission takes latest value + on$.next(); + await awaitAsync(); + sub.unsubscribe(); + + expect(results).toEqual([ + new GeneratedCredential("value", SomeCategory, SomeTime), + new GeneratedCredential("next", SomeCategory, SomeTime), + ]); + }); + + it("errors when `on$` errors", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + let error: any = null; + + // confirm no emission during subscription + generator.generate$(SomeConfiguration, { on$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + on$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `on$` completes", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + let complete = false; + + // confirm no emission during subscription + generator.generate$(SomeConfiguration, { on$ }).subscribe({ + complete: () => { + complete = true; + }, + }); + on$.complete(); + await awaitAsync(); + + expect(complete).toBeTruthy(); + }); + }); + describe("settings$", () => { it("defaults to the configuration's initial settings if settings aren't found", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -107,7 +362,7 @@ describe("CredentialGeneratorService", () => { it("reads from the active user's configuration-defined storage", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -119,7 +374,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -131,7 +386,7 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -148,7 +403,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); @@ -161,7 +416,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -180,7 +435,7 @@ describe("CredentialGeneratorService", () => { it("errors when the arbitrary user's stream errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -198,7 +453,7 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -216,7 +471,7 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -240,7 +495,7 @@ describe("CredentialGeneratorService", () => { describe("settings", () => { it("writes to the user's state", async () => { const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); subject.next({ foo: "next value" }); @@ -253,7 +508,7 @@ describe("CredentialGeneratorService", () => { it("waits for the user to become available", async () => { const singleUserId = new BehaviorSubject(null); const singleUserId$ = singleUserId.asObservable(); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); let completed = false; const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { @@ -271,7 +526,7 @@ describe("CredentialGeneratorService", () => { describe("policy$", () => { it("creates a disabled policy evaluator when there is no policy", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); @@ -281,7 +536,7 @@ describe("CredentialGeneratorService", () => { }); it("creates an active policy evaluator when there is a policy", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); @@ -293,7 +548,7 @@ describe("CredentialGeneratorService", () => { }); it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); @@ -316,7 +571,7 @@ describe("CredentialGeneratorService", () => { }); it("follows user emissions", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); @@ -340,7 +595,7 @@ describe("CredentialGeneratorService", () => { }); it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const expectedError = { some: "error" }; @@ -358,7 +613,7 @@ describe("CredentialGeneratorService", () => { }); it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 891d0016fe..d2012ecf20 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -1,5 +1,7 @@ import { + BehaviorSubject, combineLatest, + concatMap, distinctUntilChanged, endWith, filter, @@ -8,32 +10,84 @@ import { map, mergeMap, Observable, + race, switchMap, takeUntil, + withLatestFrom, } from "rxjs"; +import { Simplify } from "type-fest"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies"; +import { + OnDependency, + SingleUserDependency, + UserDependency, +} from "@bitwarden/common/tools/dependencies"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { Constraints } from "@bitwarden/common/tools/types"; -import { PolicyEvaluator } from "../abstractions"; +import { PolicyEvaluator, Randomizer } from "../abstractions"; import { mapPolicyToEvaluatorV2 } from "../rx"; import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; type Policy$Dependencies = UserDependency; type Settings$Dependencies = Partial; +type Generate$Dependencies = Simplify & Partial> & { + /** Emits the active website when subscribed. + * + * The generator does not respond to emissions of this interface; + * If it is provided, the generator blocks until a value becomes available. + * When `website$` is omitted, the generator uses the empty string instead. + * When `website$` completes, the generator completes. + * When `website$` errors, the generator forwards the error. + */ + website$?: Observable; +}; // FIXME: once the modernization is complete, switch the type parameters // in `PolicyEvaluator` and bake-in the constraints type. type Evaluator = PolicyEvaluator & Constraints; export class CredentialGeneratorService { constructor( + private randomizer: Randomizer, private stateProvider: StateProvider, private policyService: PolicyService, ) {} + /** Generates a stream of credentials + * @param configuration determines which generator's settings are loaded + * @param dependencies.on$ when specified, a new credential is emitted when + * this emits. Otherwise, a new credential is emitted when the settings + * update. + */ + generate$( + configuration: Readonly>, + dependencies?: Generate$Dependencies, + ) { + // instantiate the engine + const engine = configuration.engine.create(this.randomizer); + + // stream blocks until all of these values are received + const website$ = dependencies?.website$ ?? new BehaviorSubject(null); + const request$ = website$.pipe(map((website) => ({ website }))); + const settings$ = this.settings$(configuration, dependencies); + + // monitor completion + const requestComplete$ = request$.pipe(ignoreElements(), endWith(true)); + const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true)); + const complete$ = race(requestComplete$, settingsComplete$); + + // generation proper + const generate$ = (dependencies?.on$ ?? settings$).pipe( + withLatestFrom(request$, settings$), + concatMap(([, request, settings]) => engine.generate(request, settings)), + takeUntil(complete$), + ); + + return generate$; + } + /** Get the settings for the provided configuration * @param configuration determines which generator's settings are loaded * @param dependencies.userId$ identifies the user to which the settings are bound. @@ -82,7 +136,7 @@ export class CredentialGeneratorService { * @remarks the subject enforces policy for the settings */ async settings( - configuration: Configuration, + configuration: Readonly>, dependencies: SingleUserDependency, ) { const userId = await firstValueFrom( diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index f9b346e02b..6591b179fc 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -17,7 +17,7 @@ import { PASSPHRASE_SETTINGS } from "./storage"; const SomeUser = "some user" as UserId; -describe("Password generation strategy", () => { +describe("Passphrase generation strategy", () => { describe("toEvaluator()", () => { it("should map to the policy evaluator", async () => { const strategy = new PassphraseGeneratorStrategy(null, null); diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index fe2731f9dd..37d8b9e3fb 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -2,11 +2,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions, Policies } from "../data"; import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; -import { observe$PerUserId, sharedStateByUserId } from "../util"; +import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util"; import { PASSPHRASE_SETTINGS } from "./storage"; @@ -33,13 +33,7 @@ export class PassphraseGeneratorStrategy // algorithm async generate(options: PassphraseGenerationOptions): Promise { - const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords; - const request = { - numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min), - capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize, - number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber, - separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator, - }; + const request = optionsToEffWordListRequest(options); return this.randomizer.randomEffLongWords(request); } diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index 9ed62490c0..9ff8a3d88b 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; -import { observe$PerUserId, sharedStateByUserId, sum } from "../util"; +import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util"; import { PASSWORD_SETTINGS } from "./storage"; @@ -32,62 +32,7 @@ export class PasswordGeneratorStrategy // algorithm async generate(options: PasswordGenerationOptions): Promise { - // converts password generation option sets, which are defined by - // an "enabled" and "quantity" parameter, to the password engine's - // parameters, which represent disabled options as `undefined` - // properties. - function process( - // values read from the options - enabled: boolean, - quantity: number, - // value used if an option is missing - defaultEnabled: boolean, - defaultQuantity: number, - ) { - const isEnabled = enabled ?? defaultEnabled; - const actualQuantity = quantity ?? defaultQuantity; - const result = isEnabled ? actualQuantity : undefined; - - return result; - } - - const request = { - uppercase: process( - options.uppercase, - options.minUppercase, - DefaultPasswordGenerationOptions.uppercase, - DefaultPasswordGenerationOptions.minUppercase, - ), - lowercase: process( - options.lowercase, - options.minLowercase, - DefaultPasswordGenerationOptions.lowercase, - DefaultPasswordGenerationOptions.minLowercase, - ), - digits: process( - options.number, - options.minNumber, - DefaultPasswordGenerationOptions.number, - DefaultPasswordGenerationOptions.minNumber, - ), - special: process( - options.special, - options.minSpecial, - DefaultPasswordGenerationOptions.special, - DefaultPasswordGenerationOptions.minSpecial, - ), - ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, - all: 0, - }; - - // engine represents character sets as "include only"; you assert how many all - // characters there can be rather than a total length. This conversion has - // the character classes win, so that the result is always consistent with policy - // minimums. - const required = sum(request.uppercase, request.lowercase, request.digits, request.special); - const remaining = (options.length ?? 0) - required; - request.all = Math.max(remaining, 0); - + const request = optionsToRandomAsciiRequest(options); const result = await this.randomizer.randomAscii(request); return result; diff --git a/libs/tools/generator/core/src/types/credential-category.ts b/libs/tools/generator/core/src/types/credential-category.ts new file mode 100644 index 0000000000..54c8c5ed8e --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-category.ts @@ -0,0 +1,5 @@ +/** Kinds of credentials that can be stored by the history service + * password - a secret consisting of arbitrary characters used to authenticate a user + * passphrase - a secret consisting of words used to authenticate a user + */ +export type CredentialCategory = "password" | "passphrase"; diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 2a8b07b0e8..80d977a73c 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -1,9 +1,29 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { Constraints } from "@bitwarden/common/tools/types"; +import { Randomizer } from "../abstractions"; import { PolicyConfiguration } from "../types"; +import { CredentialCategory } from "./credential-category"; +import { CredentialGenerator } from "./credential-generator"; + export type CredentialGeneratorConfiguration = { + /** Category describing usage of the credential generated by this configuration + */ + category: CredentialCategory; + + /** An algorithm that generates credentials when ran. */ + engine: { + /** Factory for the generator + */ + // FIXME: note that this erases the engine's type so that credentials are + // generated uniformly. This property needs to be maintained for + // the credential generator, but engine configurations should return + // the underlying type. `create` may be able to do double-duty w/ an + // engine definition if `CredentialGenerator` can be made covariant. + create: (randomizer: Randomizer) => CredentialGenerator; + }; + /** Defines the stored parameters for credential generation */ settings: { /** value used when an account's settings haven't been initialized */ initial: Readonly>; diff --git a/libs/tools/generator/core/src/types/credential-generator.ts b/libs/tools/generator/core/src/types/credential-generator.ts new file mode 100644 index 0000000000..c95ff25aff --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-generator.ts @@ -0,0 +1,12 @@ +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { GeneratedCredential } from "./generated-credential"; + +/** An algorithm that generates credentials. */ +export type CredentialGenerator = { + /** Generates a credential + * @param request runtime parameters + * @param settings stored parameters + */ + generate: (request: GenerationRequest, settings: Settings) => Promise; +}; diff --git a/libs/tools/generator/core/src/types/generated-credential.spec.ts b/libs/tools/generator/core/src/types/generated-credential.spec.ts new file mode 100644 index 0000000000..a687676576 --- /dev/null +++ b/libs/tools/generator/core/src/types/generated-credential.spec.ts @@ -0,0 +1,58 @@ +import { CredentialCategory, GeneratedCredential } from "."; + +describe("GeneratedCredential", () => { + describe("constructor", () => { + it("assigns credential", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.credential).toEqual("example"); + }); + + it("assigns category", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.category).toEqual("passphrase"); + }); + + it("passes through date parameters", () => { + const result = new GeneratedCredential("example", "password", new Date(100)); + + expect(result.generationDate).toEqual(new Date(100)); + }); + + it("converts numeric dates to Dates", () => { + const result = new GeneratedCredential("example", "password", 100); + + expect(result.generationDate).toEqual(new Date(100)); + }); + }); + + it("toJSON converts from a credential into a JSON object", () => { + const credential = new GeneratedCredential("example", "password", new Date(100)); + + const result = credential.toJSON(); + + expect(result).toEqual({ + credential: "example", + category: "password" as CredentialCategory, + generationDate: 100, + }); + }); + + it("fromJSON converts Json objects into credentials", () => { + const jsonValue = { + credential: "example", + category: "password" as CredentialCategory, + generationDate: 100, + }; + + const result = GeneratedCredential.fromJSON(jsonValue); + + expect(result).toBeInstanceOf(GeneratedCredential); + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); +}); diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts new file mode 100644 index 0000000000..ff174b04a5 --- /dev/null +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -0,0 +1,47 @@ +import { Jsonify } from "type-fest"; + +import { CredentialCategory } from "./credential-category"; + +/** A credential generation result */ +export class GeneratedCredential { + /** + * Instantiates a generated credential + * @param credential The value of the generated credential (e.g. a password) + * @param category The kind of credential + * @param generationDate The date that the credential was generated. + * Numeric values should are interpreted using {@link Date.valueOf} + * semantics. + */ + constructor( + readonly credential: string, + readonly category: CredentialCategory, + generationDate: Date | number, + ) { + if (typeof generationDate === "number") { + this.generationDate = new Date(generationDate); + } else { + this.generationDate = generationDate; + } + } + + /** The date that the credential was generated */ + generationDate: Date; + + /** Constructs a credential from its `toJSON` representation */ + static fromJSON(jsonValue: Jsonify) { + return new GeneratedCredential( + jsonValue.credential, + jsonValue.category, + jsonValue.generationDate, + ); + } + + /** Serializes a credential to a JSON-compatible object */ + toJSON() { + return { + credential: this.credential, + category: this.category, + generationDate: this.generationDate.valueOf(), + }; + } +} diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 786b15b9d1..229ac1c0c3 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,8 +1,11 @@ export * from "./boundary"; export * from "./catchall-generator-options"; +export * from "./credential-category"; +export * from "./credential-generator"; export * from "./credential-generator-configuration"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; +export * from "./generated-credential"; export * from "./generator-options"; export * from "./generator-type"; export * from "./no-policy"; diff --git a/libs/tools/generator/core/src/util.spec.ts b/libs/tools/generator/core/src/util.spec.ts index 32bdc3ad3a..7ffd869535 100644 --- a/libs/tools/generator/core/src/util.spec.ts +++ b/libs/tools/generator/core/src/util.spec.ts @@ -1,4 +1,5 @@ -import { sum } from "./util"; +import { DefaultPassphraseGenerationOptions } from "./data"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util"; describe("sum", () => { it("returns 0 when the list is empty", () => { @@ -15,3 +16,411 @@ describe("sum", () => { expect(sum(1, 2, 3)).toBe(6); }); }); + +describe("optionsToRandomAsciiRequest", () => { + it("should map options", async () => { + const result = optionsToRandomAsciiRequest({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should disable uppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: false, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: undefined, + lowercase: 1, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable lowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: false, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: undefined, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable digits", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: false, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: undefined, + special: 1, + ambiguous: true, + }); + }); + + it("should disable special", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: false, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: 1, + special: undefined, + ambiguous: true, + }); + }); + + it("should override length with minimums", async () => { + const result = optionsToRandomAsciiRequest({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should default uppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 2, + ambiguous: true, + lowercase: true, + number: true, + special: true, + minUppercase: 2, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 2, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default lowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 2, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 2, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default number", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 2, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 2, + special: 0, + ambiguous: true, + }); + }); + + it("should default special", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: undefined, + ambiguous: true, + }); + }); + + it("should default minUppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minLowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 1, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minNumber", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 1, + special: 0, + ambiguous: true, + }); + }); + + it("should default minSpecial", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); +}); + +describe("optionsToEffWordListRequest", () => { + it("should map options", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default numWords", async () => { + const result = optionsToEffWordListRequest({ + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: DefaultPassphraseGenerationOptions.numWords, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default capitalize", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: DefaultPassphraseGenerationOptions.capitalize, + number: true, + separator: "!", + }); + }); + + it("should default includeNumber", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: DefaultPassphraseGenerationOptions.includeNumber, + separator: "!", + }); + }); + + it("should default wordSeparator", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + includeNumber: true, + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: DefaultPassphraseGenerationOptions.wordSeparator, + }); + }); +}); diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index cca2c75834..21e901765e 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -7,6 +7,13 @@ import { } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { + DefaultPassphraseBoundaries, + DefaultPassphraseGenerationOptions, + DefaultPasswordGenerationOptions, +} from "./data"; +import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types"; + /** construct a method that outputs a copy of `defaultValue` as an observable. */ export function observe$PerUserId( create: () => Partial, @@ -50,3 +57,79 @@ export function sharedStateByUserId(key: UserKeyDefinition, provid /** returns the sum of items in the list. */ export const sum = (...items: number[]) => (items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0); + +/* converts password generation option sets, which are defined by + * an "enabled" and "quantity" parameter, to the password engine's + * parameters, which represent disabled options as `undefined` + * properties. + */ +export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) { + // helper for processing common option sets + function process( + // values read from the options + enabled: boolean, + quantity: number, + // value used if an option is missing + defaultEnabled: boolean, + defaultQuantity: number, + ) { + const isEnabled = enabled ?? defaultEnabled; + const actualQuantity = quantity ?? defaultQuantity; + const result = isEnabled ? actualQuantity : undefined; + + return result; + } + + const request = { + uppercase: process( + options.uppercase, + options.minUppercase, + DefaultPasswordGenerationOptions.uppercase, + DefaultPasswordGenerationOptions.minUppercase, + ), + lowercase: process( + options.lowercase, + options.minLowercase, + DefaultPasswordGenerationOptions.lowercase, + DefaultPasswordGenerationOptions.minLowercase, + ), + digits: process( + options.number, + options.minNumber, + DefaultPasswordGenerationOptions.number, + DefaultPasswordGenerationOptions.minNumber, + ), + special: process( + options.special, + options.minSpecial, + DefaultPasswordGenerationOptions.special, + DefaultPasswordGenerationOptions.minSpecial, + ), + ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, + all: 0, + }; + + // engine represents character sets as "include only"; you assert how many all + // characters there can be rather than a total length. This conversion has + // the character classes win, so that the result is always consistent with policy + // minimums. + const required = sum(request.uppercase, request.lowercase, request.digits, request.special); + const remaining = (options.length ?? 0) - required; + request.all = Math.max(remaining, 0); + + return request; +} + +/* converts passphrase generation option sets to the eff word list request + */ +export function optionsToEffWordListRequest(options: PassphraseGenerationOptions) { + const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords; + const request = { + numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min), + capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize, + number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber, + separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator, + }; + + return request; +} From 744a48183b15094d3d384573e10d639c2de4e15c Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:47:18 -0400 Subject: [PATCH 05/36] Bumped client version(s) (#10975) --- apps/browser/package.json | 2 +- apps/browser/src/manifest.json | 2 +- apps/browser/src/manifest.v3.json | 2 +- package-lock.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/browser/package.json b/apps/browser/package.json index b76a1e8412..4d008b684c 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.9.0", + "version": "2024.9.1", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index f916555fef..2d7f46fa59 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.9.0", + "version": "2024.9.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 12d501ba9e..5e132774e6 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.9.0", + "version": "2024.9.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/package-lock.json b/package-lock.json index 0a9936bbf2..a1ef2eb87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,7 +192,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.9.0" + "version": "2024.9.1" }, "apps/cli": { "name": "@bitwarden/cli", From 8921230b4f5f33d0948572e47261604a6a3d731e Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 10 Sep 2024 14:15:01 -0400 Subject: [PATCH 06/36] [PM-11200] Move delete item permission to Can Manage (#10890) * Refactored the showDelete function to check if a user can manage a collection Removed the can edit or manage cipher check from the show delete function * Add check for AC vault to return true when user has admin access * Check user is an admin or custom user with editAnyCollection * Check user is an admin or custom user with editAnyCollection --- .../vault-cipher-row.component.html | 2 +- .../vault-items/vault-cipher-row.component.ts | 1 + .../vault-items/vault-items.component.html | 3 ++ .../vault-items/vault-items.component.ts | 40 +++++++++++++++---- .../individual-vault/vault.component.html | 1 + 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 524d9dff20..6a04ff6071 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -157,7 +157,7 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html index 457eff37fa..2238fa9fc8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html @@ -1,11 +1,6 @@ - From 8e4dab5eba5aecdf587d9043e37b31fd83c57010 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 11 Sep 2024 10:45:23 -0700 Subject: [PATCH 13/36] [PM-10996] Remove restrict-provider-access feature flag (#10977) --- .../components/vault/collections.component.ts | 3 - .../vault/app/vault/collections.component.ts | 3 - .../vault-collection-row.component.ts | 6 +- .../vault-items/vault-items.component.html | 1 - .../vault-items/vault-items.component.ts | 9 +-- .../bulk-delete-dialog.component.ts | 21 +------ .../collections.component.html | 2 +- .../individual-vault/collections.component.ts | 5 +- .../vault/individual-vault/vault.component.ts | 2 +- .../vault/individual-vault/view.component.ts | 12 +--- .../app/vault/org-vault/add-edit.component.ts | 11 ++-- .../vault/org-vault/attachments.component.ts | 24 ++------ .../vault/org-vault/collections.component.ts | 13 +--- .../vault-header/vault-header.component.html | 2 +- .../vault-header/vault-header.component.ts | 15 +---- .../app/vault/org-vault/vault.component.html | 1 - .../app/vault/org-vault/vault.component.ts | 60 ++++--------------- .../components/collections.component.ts | 9 +-- .../vault/components/add-edit.component.ts | 15 ++--- .../models/domain/organization.ts | 18 +----- libs/common/src/enums/feature-flag.enum.ts | 2 - .../src/vault/models/view/collection.view.ts | 8 +-- .../assign-collections.component.ts | 17 ++---- 23 files changed, 53 insertions(+), 206 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault/collections.component.ts b/apps/browser/src/vault/popup/components/vault/collections.component.ts index 1204a436f2..19d448e603 100644 --- a/apps/browser/src/vault/popup/components/vault/collections.component.ts +++ b/apps/browser/src/vault/popup/components/vault/collections.component.ts @@ -6,7 +6,6 @@ import { first } from "rxjs/operators"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -29,7 +28,6 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On private route: ActivatedRoute, private location: Location, logService: LogService, - configService: ConfigService, accountService: AccountService, toastService: ToastService, ) { @@ -40,7 +38,6 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On cipherService, organizationService, logService, - configService, accountService, toastService, ); diff --git a/apps/desktop/src/vault/app/vault/collections.component.ts b/apps/desktop/src/vault/app/vault/collections.component.ts index 7183e4bd5c..3885ca0057 100644 --- a/apps/desktop/src/vault/app/vault/collections.component.ts +++ b/apps/desktop/src/vault/app/vault/collections.component.ts @@ -3,7 +3,6 @@ import { Component } from "@angular/core"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -23,7 +22,6 @@ export class CollectionsComponent extends BaseCollectionsComponent { platformUtilsService: PlatformUtilsService, organizationService: OrganizationService, logService: LogService, - configService: ConfigService, accountService: AccountService, toastService: ToastService, ) { @@ -34,7 +32,6 @@ export class CollectionsComponent extends BaseCollectionsComponent { cipherService, organizationService, logService, - configService, accountService, toastService, ); diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index b5f910cd1a..09e7484b67 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -34,7 +34,6 @@ export class VaultCollectionRowComponent { @Input() organizations: Organization[]; @Input() groups: GroupView[]; @Input() showPermissionsColumn: boolean; - @Input() restrictProviderAccess: boolean; @Output() onEvent = new EventEmitter(); @@ -74,10 +73,7 @@ export class VaultCollectionRowComponent { } get permissionText() { - if ( - this.collection.id == Unassigned && - this.organization?.canEditUnassignedCiphers(this.restrictProviderAccess) - ) { + if (this.collection.id == Unassigned && this.organization?.canEditUnassignedCiphers) { return this.i18nService.t("canEdit"); } if ((this.collection as CollectionAdminView).assigned) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index ec7b843afc..7b427d2f12 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -106,7 +106,6 @@ [canDeleteCollection]="canDeleteCollection(item.collection)" [canEditCollection]="canEditCollection(item.collection)" [canViewCollectionInfo]="canViewCollectionInfo(item.collection)" - [restrictProviderAccess]="restrictProviderAccess" [checked]="selection.isSelected(item)" (checkedToggled)="selection.toggle(item)" (onEvent)="event($event)" diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 1e65686a0a..05f32b22ab 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -46,7 +46,6 @@ export class VaultItemsComponent { @Input() viewingOrgVault: boolean; @Input() addAccessStatus: number; @Input() addAccessToggle: boolean; - @Input() restrictProviderAccess: boolean; @Input() vaultBulkManagementActionEnabled = false; @Input() activeCollection: CollectionView | undefined; @@ -213,10 +212,7 @@ export class VaultItemsComponent { } const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); - return ( - (organization.canEditAllCiphers(this.restrictProviderAccess) && this.viewingOrgVault) || - cipher.edit - ); + return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; } protected canManageCollection(cipher: CipherView) { @@ -306,8 +302,7 @@ export class VaultItemsComponent { const [orgId] = uniqueCipherOrgIds; const organization = this.allOrganizations.find((o) => o.id === orgId); - const canEditOrManageAllCiphers = - organization?.canEditAllCiphers(this.restrictProviderAccess) && this.viewingOrgVault; + const canEditOrManageAllCiphers = organization?.canEditAllCiphers && this.viewingOrgVault; const collectionNotSelected = this.selection.selected.filter((item) => item.collection).length === 0; diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 617628a0b3..e787a31f6e 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -1,11 +1,8 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -54,10 +51,6 @@ export class BulkDeleteDialogComponent { collections: CollectionView[]; unassignedCiphers: string[]; - private restrictProviderAccess$ = this.configService.getFeatureFlag$( - FeatureFlag.RestrictProviderAccess, - ); - constructor( @Inject(DIALOG_DATA) params: BulkDeleteDialogParams, private dialogRef: DialogRef, @@ -66,7 +59,6 @@ export class BulkDeleteDialogComponent { private i18nService: I18nService, private apiService: ApiService, private collectionService: CollectionService, - private configService: ConfigService, ) { this.cipherIds = params.cipherIds ?? []; this.permanent = params.permanent; @@ -82,19 +74,13 @@ export class BulkDeleteDialogComponent { protected submit = async () => { const deletePromises: Promise[] = []; - const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$); // Unassigned ciphers under an Owner/Admin OR Custom Users With Edit will call the deleteCiphersAdmin method - if ( - this.unassignedCiphers.length && - this.organization.canEditUnassignedCiphers(restrictProviderAccess) - ) { + if (this.unassignedCiphers.length && this.organization.canEditUnassignedCiphers) { deletePromises.push(this.deleteCiphersAdmin(this.unassignedCiphers)); } if (this.cipherIds.length) { - const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$); - - if (!this.organization || !this.organization.canEditAllCiphers(restrictProviderAccess)) { + if (!this.organization || !this.organization.canEditAllCiphers) { deletePromises.push(this.deleteCiphers()); } else { deletePromises.push(this.deleteCiphersAdmin(this.cipherIds)); @@ -126,8 +112,7 @@ export class BulkDeleteDialogComponent { }; private async deleteCiphers(): Promise { - const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$); - const asAdmin = this.organization?.canEditAllCiphers(restrictProviderAccess); + const asAdmin = this.organization?.canEditAllCiphers; if (this.permanent) { await this.cipherService.deleteManyWithServer(this.cipherIds, asAdmin); } else { diff --git a/apps/web/src/app/vault/individual-vault/collections.component.html b/apps/web/src/app/vault/individual-vault/collections.component.html index e4029ef866..028d91ad34 100644 --- a/apps/web/src/app/vault/individual-vault/collections.component.html +++ b/apps/web/src/app/vault/individual-vault/collections.component.html @@ -32,7 +32,7 @@ [(ngModel)]="$any(c).checked" name="Collection[{{ i }}].Checked" appStopProp - [disabled]="!c.canEditItems(this.organization, this.restrictProviderAccess)" + [disabled]="!c.canEditItems(this.organization)" /> {{ c.name }} diff --git a/apps/web/src/app/vault/individual-vault/collections.component.ts b/apps/web/src/app/vault/individual-vault/collections.component.ts index cd52e41e38..0fc5b88d61 100644 --- a/apps/web/src/app/vault/individual-vault/collections.component.ts +++ b/apps/web/src/app/vault/individual-vault/collections.component.ts @@ -4,7 +4,6 @@ import { Component, Inject, OnDestroy } from "@angular/core"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -25,7 +24,6 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On cipherService: CipherService, organizationSerivce: OrganizationService, logService: LogService, - configService: ConfigService, accountService: AccountService, protected dialogRef: DialogRef, @Inject(DIALOG_DATA) params: CollectionsDialogParams, @@ -38,7 +36,6 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On cipherService, organizationSerivce, logService, - configService, accountService, toastService, ); @@ -55,7 +52,7 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On } check(c: CollectionView, select?: boolean) { - if (!c.canEditItems(this.organization, this.restrictProviderAccess)) { + if (!c.canEditItems(this.organization)) { return; } (c as any).checked = select == null ? !(c as any).checked : select; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index d47a8be252..dcf62235d1 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -1161,7 +1161,7 @@ export class VaultComponent implements OnInit, OnDestroy { } const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); - return organization.canEditAllCiphers(false); + return organization.canEditAllCiphers; } private go(queryParams: any = null) { 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 2a3865cd1d..77019b6f32 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,13 +1,11 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core"; +import { Component, EventEmitter, Inject, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { Subject } from "rxjs"; import { OrganizationService } 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 { I18nService } 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"; @@ -50,9 +48,7 @@ export class ViewComponent implements OnInit, OnDestroy { cipher: CipherView; onDeletedCipher = new EventEmitter(); cipherTypeString: string; - cipherEditUrl: string; organization: Organization; - restrictProviderAccess = false; protected destroy$ = new Subject(); @@ -67,7 +63,6 @@ export class ViewComponent implements OnInit, OnDestroy { private toastService: ToastService, private organizationService: OrganizationService, private router: Router, - private configService: ConfigService, ) {} /** @@ -79,9 +74,6 @@ export class ViewComponent implements OnInit, OnDestroy { if (this.cipher.organizationId) { this.organization = await this.organizationService.get(this.cipher.organizationId); } - this.restrictProviderAccess = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); } /** @@ -132,7 +124,7 @@ export class ViewComponent implements OnInit, OnDestroy { * Helper method to delete cipher. */ protected async deleteCipher(): Promise { - const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess); + const asAdmin = this.organization?.canEditAllCiphers; if (this.cipher.isDeleted) { await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); } else { diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 6886da371e..9129ed9cda 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -83,7 +83,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected loadCollections() { - if (!this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (!this.organization.canEditAllCiphers) { return super.loadCollections(); } return Promise.resolve(this.collections); @@ -93,10 +93,7 @@ export class AddEditComponent extends BaseAddEditComponent { // Calling loadCipher first to assess if the cipher is unassigned. If null use apiService getCipherAdmin const firstCipherCheck = await super.loadCipher(); - if ( - !this.organization.canEditAllCiphers(this.restrictProviderAccess) && - firstCipherCheck != null - ) { + if (!this.organization.canEditAllCiphers && firstCipherCheck != null) { return firstCipherCheck; } const response = await this.apiService.getCipherAdmin(this.cipherId); @@ -109,7 +106,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected encryptCipher(userId: UserId) { - if (!this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (!this.organization.canEditAllCiphers) { return super.encryptCipher(userId); } @@ -117,7 +114,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected async deleteCipher() { - if (!this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (!this.organization.canEditAllCiphers) { return super.deleteCipher(); } return this.cipher.isDeleted diff --git a/apps/web/src/app/vault/org-vault/attachments.component.ts b/apps/web/src/app/vault/org-vault/attachments.component.ts index f02ac69310..9ebb917aaf 100644 --- a/apps/web/src/app/vault/org-vault/attachments.component.ts +++ b/apps/web/src/app/vault/org-vault/attachments.component.ts @@ -1,12 +1,9 @@ import { Component, OnInit } from "@angular/core"; -import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -30,8 +27,6 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On viewOnly = false; organization: Organization; - private restrictProviderAccess = false; - constructor( cipherService: CipherService, i18nService: I18nService, @@ -44,7 +39,6 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, - private configService: ConfigService, ) { super( cipherService, @@ -63,22 +57,16 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On async ngOnInit() { await super.ngOnInit(); - this.restrictProviderAccess = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.RestrictProviderAccess), - ); } protected async reupload(attachment: AttachmentView) { - if ( - this.organization.canEditAllCiphers(this.restrictProviderAccess) && - this.showFixOldAttachments(attachment) - ) { + if (this.organization.canEditAllCiphers && this.showFixOldAttachments(attachment)) { await super.reuploadCipherAttachment(attachment, true); } } protected async loadCipher() { - if (!this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (!this.organization.canEditAllCiphers) { return await super.loadCipher(); } const response = await this.apiService.getCipherAdmin(this.cipherId); @@ -90,20 +78,18 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On this.cipherDomain, file, userId, - this.organization.canEditAllCiphers(this.restrictProviderAccess), + this.organization.canEditAllCiphers, ); } protected deleteCipherAttachment(attachmentId: string) { - if (!this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (!this.organization.canEditAllCiphers) { return super.deleteCipherAttachment(attachmentId); } return this.apiService.deleteCipherAttachmentAdmin(this.cipherId, attachmentId); } protected showFixOldAttachments(attachment: AttachmentView) { - return ( - attachment.key == null && this.organization.canEditAllCiphers(this.restrictProviderAccess) - ); + return attachment.key == null && this.organization.canEditAllCiphers; } } diff --git a/apps/web/src/app/vault/org-vault/collections.component.ts b/apps/web/src/app/vault/org-vault/collections.component.ts index 72816d5321..877e7dfe16 100644 --- a/apps/web/src/app/vault/org-vault/collections.component.ts +++ b/apps/web/src/app/vault/org-vault/collections.component.ts @@ -5,7 +5,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -37,7 +36,6 @@ export class CollectionsComponent extends BaseCollectionsComponent { organizationService: OrganizationService, private apiService: ApiService, logService: LogService, - configService: ConfigService, accountService: AccountService, protected dialogRef: DialogRef, @Inject(DIALOG_DATA) params: OrgVaultCollectionsDialogParams, @@ -50,7 +48,6 @@ export class CollectionsComponent extends BaseCollectionsComponent { cipherService, organizationService, logService, - configService, accountService, dialogRef, params, @@ -65,10 +62,7 @@ export class CollectionsComponent extends BaseCollectionsComponent { protected async loadCipher() { // if cipher is unassigned use apiService. We can see this by looking at this.collectionIds - if ( - !this.organization.canEditAllCiphers(this.restrictProviderAccess) && - this.collectionIds.length !== 0 - ) { + if (!this.organization.canEditAllCiphers && this.collectionIds.length !== 0) { return await super.loadCipher(); } const response = await this.apiService.getCipherAdmin(this.cipherId); @@ -90,10 +84,7 @@ export class CollectionsComponent extends BaseCollectionsComponent { } protected saveCollections() { - if ( - this.organization.canEditAllCiphers(this.restrictProviderAccess) || - this.collectionIds.length === 0 - ) { + if (this.organization.canEditAllCiphers || this.collectionIds.length === 0) { const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds); return this.apiService.putCipherCollectionsAdmin(this.cipherId, request); } else { diff --git a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html index 72b1284b86..9189e646d2 100644 --- a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html @@ -93,7 +93,7 @@ diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index f9652fe08c..add1ecbe3e 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -43,9 +43,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } 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"; @@ -172,17 +170,8 @@ export class VaultComponent implements OnInit, OnDestroy { protected orgRevokedUsers: OrganizationUserUserDetailsResponse[]; - private _restrictProviderAccessFlagEnabled: boolean; - protected get restrictProviderAccessEnabled(): boolean { - return this._restrictProviderAccessFlagEnabled; - } - protected get hideVaultFilters(): boolean { - return ( - this.restrictProviderAccessEnabled && - this.organization?.isProviderUser && - !this.organization?.isMember - ); + return this.organization?.isProviderUser && !this.organization?.isMember; } private searchText$ = new Subject(); @@ -218,7 +207,6 @@ export class VaultComponent implements OnInit, OnDestroy { private apiService: ApiService, private collectionService: CollectionService, private organizationUserApiService: OrganizationUserApiService, - protected configService: ConfigService, private toastService: ToastService, private accountService: AccountService, ) {} @@ -230,10 +218,6 @@ export class VaultComponent implements OnInit, OnDestroy { : "trashCleanupWarning", ); - this._restrictProviderAccessFlagEnabled = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); - const filter$ = this.routedVaultFilterService.filter$; const organizationId$ = filter$.pipe( map((filter) => filter.organizationId), @@ -325,7 +309,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( map((collections) => { // Users that can edit all ciphers can implicitly add to / edit within any collection - if (this.organization.canEditAllCiphers(this.restrictProviderAccessEnabled)) { + if (this.organization.canEditAllCiphers) { return collections; } // The user is only allowed to add/edit items to assigned collections that are not readonly @@ -362,16 +346,12 @@ export class VaultComponent implements OnInit, OnDestroy { // Restricted providers (who are not members) do not have access org cipher endpoint below // Return early to avoid 404 response - if ( - this.restrictProviderAccessEnabled && - !organization.isMember && - organization.isProviderUser - ) { + if (!organization.isMember && organization.isProviderUser) { return []; } // If the user can edit all ciphers for the organization then fetch them ALL. - if (organization.canEditAllCiphers(this.restrictProviderAccessEnabled)) { + if (organization.canEditAllCiphers) { ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); } else { // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). @@ -476,11 +456,8 @@ export class VaultComponent implements OnInit, OnDestroy { ]).pipe( map(([filter, collection, organization]) => { return ( - (filter.collectionId === Unassigned && - !organization.canEditUnassignedCiphers(this.restrictProviderAccessEnabled)) || - (!organization.canEditAllCiphers(this.restrictProviderAccessEnabled) && - collection != undefined && - !collection.node.assigned) + (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || + (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) ); }), shareReplay({ refCount: true, bufferSize: 1 }), @@ -525,7 +502,7 @@ export class VaultComponent implements OnInit, OnDestroy { } const canEditCipher = - organization.canEditAllCiphers(this.restrictProviderAccessEnabled) || + organization.canEditAllCiphers || (await firstValueFrom(allCipherMap$))[cipherId] != undefined; if (canEditCipher) { @@ -758,15 +735,9 @@ export class VaultComponent implements OnInit, OnDestroy { this.allCollectionsWithoutUnassigned$.pipe( map((c) => { return c.sort((a, b) => { - if ( - a.canEditItems(this.organization, this.restrictProviderAccessEnabled) && - !b.canEditItems(this.organization, this.restrictProviderAccessEnabled) - ) { + if (a.canEditItems(this.organization) && !b.canEditItems(this.organization)) { return -1; - } else if ( - !a.canEditItems(this.organization, this.restrictProviderAccessEnabled) && - b.canEditItems(this.organization, this.restrictProviderAccessEnabled) - ) { + } else if (!a.canEditItems(this.organization) && b.canEditItems(this.organization)) { return 1; } else { return a.name.localeCompare(b.name); @@ -1001,9 +972,7 @@ export class VaultComponent implements OnInit, OnDestroy { const unassignedCiphers: string[] = []; // If user has edit all Access no need to check for unassigned ciphers - const canEditAll = this.organization.canEditAllCiphers(this.restrictProviderAccessEnabled); - - if (canEditAll) { + if (this.organization.canEditAllCiphers) { ciphers.map((cipher) => { editAccessCiphers.push(cipher.id); }); @@ -1042,7 +1011,7 @@ export class VaultComponent implements OnInit, OnDestroy { } async deleteCipher(c: CipherView): Promise { - if (!c.edit && !this.organization.canEditAllCiphers(this.restrictProviderAccessEnabled)) { + if (!c.edit && !this.organization.canEditAllCiphers) { this.showMissingPermissionsError(); return; } @@ -1146,9 +1115,7 @@ export class VaultComponent implements OnInit, OnDestroy { const canDeleteCollections = collections == null || collections.every((c) => c.canDelete(organization)); const canDeleteCiphers = - ciphers == null || - ciphers.every((c) => c.edit) || - this.organization.canEditAllCiphers(this.restrictProviderAccessEnabled); + ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers; if (!canDeleteCiphers || !canDeleteCollections) { this.showMissingPermissionsError(); @@ -1351,8 +1318,7 @@ export class VaultComponent implements OnInit, OnDestroy { } protected deleteCipherWithServer(id: string, permanent: boolean, isUnassigned: boolean) { - const asAdmin = - this.organization?.canEditAllCiphers(this.restrictProviderAccessEnabled) || isUnassigned; + const asAdmin = this.organization?.canEditAllCiphers || isUnassigned; return permanent ? this.cipherService.deleteWithServer(id, asAdmin) : this.cipherService.softDeleteWithServer(id, asAdmin); diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index 4f16628618..d6801aa155 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -4,8 +4,6 @@ import { firstValueFrom, map } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -27,7 +25,6 @@ export class CollectionsComponent implements OnInit { collectionIds: string[]; collections: CollectionView[] = []; organization: Organization; - restrictProviderAccess: boolean; protected cipherDomain: Cipher; @@ -38,15 +35,11 @@ export class CollectionsComponent implements OnInit { protected cipherService: CipherService, protected organizationService: OrganizationService, private logService: LogService, - private configService: ConfigService, private accountService: AccountService, private toastService: ToastService, ) {} async ngOnInit() { - this.restrictProviderAccess = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); await this.load(); } @@ -76,7 +69,7 @@ export class CollectionsComponent implements OnInit { async submit(): Promise { const selectedCollectionIds = this.collections .filter((c) => { - if (this.organization.canEditAllCiphers(this.restrictProviderAccess)) { + if (this.organization.canEditAllCiphers) { return !!(c as any).checked; } else { return !!(c as any).checked && c.readOnly == null; diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 96589fd2b0..45475440d0 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -13,7 +13,6 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin- import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -92,8 +91,6 @@ export class AddEditComponent implements OnInit, OnDestroy { private personalOwnershipPolicyAppliesToActiveUser: boolean; private previousCipherId: string; - protected restrictProviderAccess = false; - get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -182,10 +179,6 @@ export class AddEditComponent implements OnInit, OnDestroy { } async ngOnInit() { - this.restrictProviderAccess = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); - this.policyService .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) .pipe( @@ -683,11 +676,11 @@ export class AddEditComponent implements OnInit, OnDestroy { protected saveCipher(cipher: Cipher) { const isNotClone = this.editMode && !this.cloneMode; - let orgAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess); + let orgAdmin = this.organization?.canEditAllCiphers; // if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection if (!cipher.collectionIds) { - orgAdmin = this.organization?.canEditUnassignedCiphers(this.restrictProviderAccess); + orgAdmin = this.organization?.canEditUnassignedCiphers; } return this.cipher.id == null @@ -696,14 +689,14 @@ export class AddEditComponent implements OnInit, OnDestroy { } protected deleteCipher() { - const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess); + const asAdmin = this.organization?.canEditAllCiphers; return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, asAdmin) : this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); } protected restoreCipher() { - const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess); + const asAdmin = this.organization?.canEditAllCiphers; return this.cipherService.restoreWithServer(this.cipher.id, asAdmin); } diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 490c799ad1..8c28bcb493 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -183,14 +183,7 @@ export class Organization { return this.isAdmin || this.permissions.editAnyCollection; } - canEditUnassignedCiphers(restrictProviderAccessFlagEnabled: boolean) { - // Providers can access items until the restrictProviderAccess flag is enabled - // After the flag is enabled and removed, this block will be deleted - // so that they permanently lose access to items - if (this.isProviderUser && !restrictProviderAccessFlagEnabled) { - return true; - } - + get canEditUnassignedCiphers() { return ( this.type === OrganizationUserType.Admin || this.type === OrganizationUserType.Owner || @@ -198,14 +191,7 @@ export class Organization { ); } - canEditAllCiphers(restrictProviderAccessFlagEnabled: boolean) { - // Providers can access items until the restrictProviderAccess flag is enabled - // After the flag is enabled and removed, this block will be deleted - // so that they permanently lose access to items - if (this.isProviderUser && !restrictProviderAccessFlagEnabled) { - return true; - } - + get canEditAllCiphers() { // The allowAdminAccessToAllCollectionItems flag can restrict admins // Custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag return ( diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 53baea14c5..e7ece5b36a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,7 +12,6 @@ export enum FeatureFlag { EnableDeleteProvider = "AC-1218-delete-provider", ExtensionRefresh = "extension-refresh", PersistPopupView = "persist-popup-view", - RestrictProviderAccess = "restrict-provider-access", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", EmailVerification = "email-verification", @@ -59,7 +58,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableDeleteProvider]: FALSE, [FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.PersistPopupView]: FALSE, - [FeatureFlag.RestrictProviderAccess]: FALSE, [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, [FeatureFlag.EmailVerification]: FALSE, diff --git a/libs/common/src/vault/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts index 0a05007b66..873f538ca6 100644 --- a/libs/common/src/vault/models/view/collection.view.ts +++ b/libs/common/src/vault/models/view/collection.view.ts @@ -38,18 +38,14 @@ export class CollectionView implements View, ITreeNodeObject { } } - canEditItems(org: Organization, restrictProviderAccess: boolean): boolean { + canEditItems(org: Organization): boolean { if (org != null && org.id !== this.organizationId) { throw new Error( "Id of the organization provided does not match the org id of the collection.", ); } - return ( - org?.canEditAllCiphers(restrictProviderAccess) || - this.manage || - (this.assigned && !this.readOnly) - ); + return org?.canEditAllCiphers || this.manage || (this.assigned && !this.readOnly); } /** diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index 188fc543ef..db4d61691c 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -11,12 +11,12 @@ import { } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { - Observable, - Subject, combineLatest, firstValueFrom, map, + Observable, shareReplay, + Subject, switchMap, takeUntil, tap, @@ -27,8 +27,6 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -170,7 +168,6 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI constructor( private cipherService: CipherService, private i18nService: I18nService, - private configService: ConfigService, private organizationService: OrganizationService, private collectionService: CollectionService, private formBuilder: FormBuilder, @@ -179,10 +176,6 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI ) {} async ngOnInit() { - const restrictProviderAccess = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); - this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); @@ -193,7 +186,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI this.showOrgSelector = true; } - await this.initializeItems(this.selectedOrgId, restrictProviderAccess); + await this.initializeItems(this.selectedOrgId); if (this.selectedOrgId && this.selectedOrgId !== MY_VAULT_ID) { await this.handleOrganizationCiphers(); @@ -339,7 +332,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI } } - private async initializeItems(organizationId: OrganizationId, restrictProviderAccess: boolean) { + private async initializeItems(organizationId: OrganizationId) { this.totalItemCount = this.params.ciphers.length; // If organizationId is not present or organizationId is MyVault, then all ciphers are considered personal items @@ -354,7 +347,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI const org = await this.organizationService.get(organizationId); this.orgName = org.name; - this.editableItems = org.canEditAllCiphers(restrictProviderAccess) + this.editableItems = org.canEditAllCiphers ? this.params.ciphers : this.params.ciphers.filter((c) => c.edit); From a0a0a7ae1368262850d6fbe14a860077ffe4afd1 Mon Sep 17 00:00:00 2001 From: Victoria League Date: Wed, 11 Sep 2024 14:54:07 -0400 Subject: [PATCH 14/36] [CL-395] Add and use art colors in svgs (#10990) --- .../vault-ui-onboarding.component.ts | 14 ++++---- libs/auth/src/angular/icons/lock.icon.ts | 22 ++++++------- libs/components/src/icon/icons/no-results.ts | 22 ++++++------- libs/components/src/tw-theme.css | 6 ++++ libs/components/tailwind.config.base.js | 4 +++ .../send/send-ui/src/icons/no-send.icon.ts | 14 ++++---- .../send-ui/src/icons/send-created.icon.ts | 12 +++---- libs/vault/src/icons/deactivated-org.ts | 32 +++++++++---------- libs/vault/src/icons/empty-trash.ts | 20 ++++++------ libs/vault/src/icons/no-folders.ts | 26 +++++++-------- libs/vault/src/icons/vault.ts | 18 +++++------ 11 files changed, 100 insertions(+), 90 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts index 9022a3b048..20b39c5a88 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts @@ -13,13 +13,13 @@ import { const announcementIcon = svgIcon` - - - - - - - + + + + + + + diff --git a/libs/auth/src/angular/icons/lock.icon.ts b/libs/auth/src/angular/icons/lock.icon.ts index 981b520010..198733d0dc 100644 --- a/libs/auth/src/angular/icons/lock.icon.ts +++ b/libs/auth/src/angular/icons/lock.icon.ts @@ -2,16 +2,16 @@ import { svgIcon } from "@bitwarden/components"; export const LockIcon = svgIcon` - - - - - - - - - - - + + + + + + + + + + + `; diff --git a/libs/components/src/icon/icons/no-results.ts b/libs/components/src/icon/icons/no-results.ts index 02e03f4c4e..7ed886a06e 100644 --- a/libs/components/src/icon/icons/no-results.ts +++ b/libs/components/src/icon/icons/no-results.ts @@ -2,17 +2,17 @@ import { svgIcon } from "../icon"; export const NoResults = svgIcon` - - - - - - - - - - - + + + + + + + + + + + `; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 6234ba380b..6e5bb32eda 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -42,6 +42,9 @@ --color-info-600: 85 85 85; --color-info-700: 59 58 58; + --color-art-primary: 2 15 102; + --color-art-accent: 85 85 85; + --color-text-main: 33 37 41; --color-text-muted: 109 117 126; --color-text-contrast: 255 255 255; @@ -90,6 +93,9 @@ --color-info-600: 164 176 198; --color-info-700: 209 215 226; + --color-art-primary: 226 227 228; + --color-art-accent: 164 176 198; + --color-text-main: 255 255 255; --color-text-muted: 186 192 206; --color-text-contrast: 25 30 38; diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 236baed74c..537e731f14 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -54,6 +54,10 @@ module.exports = { 600: rgba("--color-info-600"), 700: rgba("--color-info-700"), }, + art: { + primary: rgba("--color-art-primary"), + accent: rgba("--color-art-accent"), + }, text: { main: rgba("--color-text-main"), muted: rgba("--color-text-muted"), diff --git a/libs/tools/send/send-ui/src/icons/no-send.icon.ts b/libs/tools/send/send-ui/src/icons/no-send.icon.ts index e1442ad702..555d802460 100644 --- a/libs/tools/send/send-ui/src/icons/no-send.icon.ts +++ b/libs/tools/send/send-ui/src/icons/no-send.icon.ts @@ -2,12 +2,12 @@ import { svgIcon } from "@bitwarden/components"; export const NoSendsIcon = svgIcon` - - - - - - - + + + + + + + `; diff --git a/libs/tools/send/send-ui/src/icons/send-created.icon.ts b/libs/tools/send/send-ui/src/icons/send-created.icon.ts index bb4bc2dd3b..099baebb9a 100644 --- a/libs/tools/send/send-ui/src/icons/send-created.icon.ts +++ b/libs/tools/send/send-ui/src/icons/send-created.icon.ts @@ -2,12 +2,12 @@ import { svgIcon } from "@bitwarden/components"; export const SendCreatedIcon = svgIcon` - - - - - - + + + + + + diff --git a/libs/vault/src/icons/deactivated-org.ts b/libs/vault/src/icons/deactivated-org.ts index 7d1871a091..5f7c910aec 100644 --- a/libs/vault/src/icons/deactivated-org.ts +++ b/libs/vault/src/icons/deactivated-org.ts @@ -3,22 +3,22 @@ import { svgIcon } from "@bitwarden/components"; export const DeactivatedOrg = svgIcon` - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/libs/vault/src/icons/empty-trash.ts b/libs/vault/src/icons/empty-trash.ts index e112309e32..df74602e6d 100644 --- a/libs/vault/src/icons/empty-trash.ts +++ b/libs/vault/src/icons/empty-trash.ts @@ -3,16 +3,16 @@ import { svgIcon } from "@bitwarden/components"; export const EmptyTrash = svgIcon` - - - - - - - - - + + + + + + + + + - + `; diff --git a/libs/vault/src/icons/no-folders.ts b/libs/vault/src/icons/no-folders.ts index 478cb6a6b7..666bfc86c4 100644 --- a/libs/vault/src/icons/no-folders.ts +++ b/libs/vault/src/icons/no-folders.ts @@ -2,18 +2,18 @@ import { svgIcon } from "@bitwarden/components"; export const NoFolders = svgIcon` - - - - - - - - - - - - - + + + + + + + + + + + + + `; diff --git a/libs/vault/src/icons/vault.ts b/libs/vault/src/icons/vault.ts index 4afe0920f7..577080b0f3 100644 --- a/libs/vault/src/icons/vault.ts +++ b/libs/vault/src/icons/vault.ts @@ -3,15 +3,15 @@ import { svgIcon } from "@bitwarden/components"; export const Vault = svgIcon` - - - - - - - - - + + + + + + + + + `; From 2cfbfcbdfe5052e7cc06fa2630df27d598c104f0 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:03:39 -0400 Subject: [PATCH 15/36] Run prettier (#10993) --- .../app/billing/organizations/change-plan-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index c893f3832d..14b6864d64 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -62,7 +62,7 @@ class="tw-px-2 tw-py-4" [ngClass]="{ 'tw-py-1': !(selectableProduct === selectedPlan), - 'tw-py-0': selectableProduct === selectedPlan + 'tw-py-0': selectableProduct === selectedPlan, }" >

From 89751f46d6f3d68e2f08630d0448c6becc4268a0 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 11 Sep 2024 15:27:53 -0400 Subject: [PATCH 16/36] [PM-254] Set PDF Attachments in Web to download, add success toast (#10757) * add success toast to pdf attachment download in web * update desktop attachments for toastService * removed trailing comma --------- Co-authored-by: gbubemismith Co-authored-by: SmithThe4th --- apps/browser/src/_locales/en/messages.json | 3 +++ .../vault/popup/components/vault/attachments.component.ts | 4 +++- apps/desktop/src/locales/en/messages.json | 3 +++ apps/desktop/src/vault/app/vault/attachments.component.ts | 4 +++- .../attachments/emergency-access-attachments.component.ts | 4 +++- apps/web/src/app/core/web-file-download.service.ts | 6 ++---- .../app/vault/individual-vault/attachments.component.ts | 4 +++- apps/web/src/app/vault/org-vault/attachments.component.ts | 4 +++- apps/web/src/locales/en/messages.json | 3 +++ .../angular/src/vault/components/attachments.component.ts | 8 +++++++- 10 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 05525be6ff..4e6f27bdd2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4308,6 +4308,9 @@ }, "enterprisePolicyRequirementsApplied": { "message": "Enterprise policy requirements have been applied to this setting" + }, + "fileSavedToDevice": { + "message": "File saved to device. Manage from your device downloads." }, "showCharacterCount": { "message": "Show character count" diff --git a/apps/browser/src/vault/popup/components/vault/attachments.component.ts b/apps/browser/src/vault/popup/components/vault/attachments.component.ts index ee6f1ac7d0..75819689b4 100644 --- a/apps/browser/src/vault/popup/components/vault/attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments.component.ts @@ -14,7 +14,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Component({ selector: "app-vault-attachments", @@ -38,6 +38,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + toastService: ToastService, ) { super( cipherService, @@ -52,6 +53,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On dialogService, billingAccountProfileStateService, accountService, + toastService, ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 5345c6e15a..4ef70887a4 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3061,5 +3061,8 @@ }, "ssoError": { "message": "No free ports could be found for the sso login." + }, + "fileSavedToDevice": { + "message": "File saved to device. Manage from your device downloads." } } diff --git a/apps/desktop/src/vault/app/vault/attachments.component.ts b/apps/desktop/src/vault/app/vault/attachments.component.ts index b1ddcbc7e7..2e25d39087 100644 --- a/apps/desktop/src/vault/app/vault/attachments.component.ts +++ b/apps/desktop/src/vault/app/vault/attachments.component.ts @@ -11,7 +11,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Component({ selector: "app-vault-attachments", @@ -30,6 +30,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + toastService: ToastService, ) { super( cipherService, @@ -44,6 +45,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { dialogService, billingAccountProfileStateService, accountService, + toastService, ); } } diff --git a/apps/web/src/app/auth/settings/emergency-access/attachments/emergency-access-attachments.component.ts b/apps/web/src/app/auth/settings/emergency-access/attachments/emergency-access-attachments.component.ts index 9d763886fb..e0a6f6c53d 100644 --- a/apps/web/src/app/auth/settings/emergency-access/attachments/emergency-access-attachments.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/attachments/emergency-access-attachments.component.ts @@ -12,7 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Component({ selector: "emergency-access-attachments", @@ -34,6 +34,7 @@ export class EmergencyAccessAttachmentsComponent extends BaseAttachmentsComponen dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + toastService: ToastService, ) { super( cipherService, @@ -48,6 +49,7 @@ export class EmergencyAccessAttachmentsComponent extends BaseAttachmentsComponen dialogService, billingAccountProfileStateService, accountService, + toastService, ); } diff --git a/apps/web/src/app/core/web-file-download.service.ts b/apps/web/src/app/core/web-file-download.service.ts index 743048dc3b..ad034702a5 100644 --- a/apps/web/src/app/core/web-file-download.service.ts +++ b/apps/web/src/app/core/web-file-download.service.ts @@ -12,14 +12,12 @@ export class WebFileDownloadService implements FileDownloadService { download(request: FileDownloadRequest): void { const builder = new FileDownloadBuilder(request); const a = window.document.createElement("a"); - if (builder.downloadMethod === "save") { - a.download = request.fileName; - } else if (!this.platformUtilsService.isSafari()) { + if (!this.platformUtilsService.isSafari()) { a.rel = "noreferrer"; a.target = "_blank"; } a.href = URL.createObjectURL(builder.blob); - a.style.position = "fixed"; + a.download = request.fileName; window.document.body.appendChild(a); a.click(); window.document.body.removeChild(a); diff --git a/apps/web/src/app/vault/individual-vault/attachments.component.ts b/apps/web/src/app/vault/individual-vault/attachments.component.ts index 7a5706319e..b578efcae6 100644 --- a/apps/web/src/app/vault/individual-vault/attachments.component.ts +++ b/apps/web/src/app/vault/individual-vault/attachments.component.ts @@ -12,7 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Component({ selector: "app-vault-attachments", @@ -33,6 +33,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + toastService: ToastService, ) { super( cipherService, @@ -47,6 +48,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { dialogService, billingAccountProfileStateService, accountService, + toastService, ); } diff --git a/apps/web/src/app/vault/org-vault/attachments.component.ts b/apps/web/src/app/vault/org-vault/attachments.component.ts index 9ebb917aaf..2bba4d389c 100644 --- a/apps/web/src/app/vault/org-vault/attachments.component.ts +++ b/apps/web/src/app/vault/org-vault/attachments.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; import { AttachmentsComponent as BaseAttachmentsComponent } from "../individual-vault/attachments.component"; @@ -39,6 +39,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On dialogService: DialogService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, + toastService: ToastService, ) { super( cipherService, @@ -52,6 +53,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On dialogService, billingAccountProfileStateService, accountService, + toastService, ); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 6834e35015..ffb82cd816 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9070,6 +9070,9 @@ "secretsManagerWithFreePasswordManagerInfo": { "message": "Your complementary one year Password Manager subscription will upgrade to the selected plan. You will not be charged until the complimentary period is over." }, + "fileSavedToDevice": { + "message": "File saved to device. Manage from your device downloads." + }, "publicApi": { "message": "Public API", "description": "The text, 'API', is an acronymn and should not be translated." diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index e377427eb8..4ae68c9ca9 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -17,7 +17,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Directive() export class AttachmentsComponent implements OnInit { @@ -49,6 +49,7 @@ export class AttachmentsComponent implements OnInit { protected dialogService: DialogService, protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, + protected toastService: ToastService, ) {} async ngOnInit() { @@ -182,6 +183,11 @@ export class AttachmentsComponent implements OnInit { fileName: attachment.fileName, blobData: decBuf, }); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("fileSavedToDevice"), + }); } catch (e) { this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred")); } From 3be5c4800b3a281bd43e6141f77111c7eafc7d05 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 12 Sep 2024 10:21:23 -0700 Subject: [PATCH 18/36] Do not test napi crate on windows (#11003) * Do not test napi crate on windows possibly related to https://github.com/napi-rs/napi-rs/issues/1405. We are seeing buffer overflows in ci due to repeated Node-API GetProcAddress failures. We don't have any tests in the napi crate, so there's no harm in removing those tests right now. If we have tests there in the future, we'll need to actually fix this. However, the napi crate is just a wiring crate, so maybe we won't ever have any unit tests there. * include crate in name * Remove crate axis --- .github/workflows/test.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b4cd52ac8..8d4067c116 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -138,7 +138,12 @@ jobs: eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)" cargo test -- --test-threads=1 - - name: Test Windows / macOS - if: ${{ matrix.os!='ubuntu-latest' }} + - name: Test macOS + if: ${{ matrix.os=='macos-latest' }} working-directory: ./apps/desktop/desktop_native run: cargo test -- --test-threads=1 + + - name: Test Windows + if: ${{ matrix.os=='windows-latest'}} + working-directory: ./apps/desktop/desktop_native/core + run: cargo test -- --test-threads=1 From 07d2e364963569974a685cc328b628bcd823ab4c Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Thu, 12 Sep 2024 13:47:35 -0400 Subject: [PATCH 19/36] [PM-10914] add option to delete all folders if migration fails (#10983) * add option to delete all folders if migration fails * update text and flow to reattempt migration * clear encrypted folders as well on delete all * Update messaging --- .../migrate-legacy-encryption.component.ts | 24 ++++++++++++++++--- apps/web/src/locales/en/messages.json | 6 +++++ .../folder/folder-api.service.abstraction.ts | 1 + .../services/folder/folder-api.service.ts | 5 ++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts index 68eaae618f..6c894f4fa8 100644 --- a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts @@ -7,9 +7,9 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { I18nService } 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 { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../shared"; import { UserKeyRotationModule } from "../key-rotation/user-key-rotation.module"; @@ -31,12 +31,13 @@ export class MigrateFromLegacyEncryptionComponent { private accountService: AccountService, private keyRotationService: UserKeyRotationService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private cryptoService: CryptoService, private messagingService: MessagingService, private logService: LogService, private syncService: SyncService, private toastService: ToastService, + private dialogService: DialogService, + private folderApiService: FolderApiServiceAbstraction, ) {} submit = async () => { @@ -69,6 +70,23 @@ export class MigrateFromLegacyEncryptionComponent { }); this.messagingService.send("logout"); } catch (e) { + // If the error is due to missing folders, we can delete all folders and try again + if (e.message === "All existing folders must be included in the rotation.") { + const deleteFolders = await this.dialogService.openSimpleDialog({ + type: "warning", + title: { key: "encryptionKeyUpdateCannotProceed" }, + content: { key: "keyUpdateFoldersFailed" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + }); + + if (deleteFolders) { + await this.folderApiService.deleteAll(); + await this.syncService.fullSync(true, true); + await this.submit(); + return; + } + } this.logService.error(e); throw e; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ffb82cd816..c74fd3386a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3894,6 +3894,12 @@ } } }, + "encryptionKeyUpdateCannotProceed": { + "message": "Encryption key update cannot proceed" + }, + "keyUpdateFoldersFailed": { + "message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed." + }, "keyUpdated": { "message": "Key updated" }, diff --git a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts index d29ff71290..1762400b3d 100644 --- a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts @@ -5,4 +5,5 @@ export class FolderApiServiceAbstraction { save: (folder: Folder) => Promise; delete: (id: string) => Promise; get: (id: string) => Promise; + deleteAll: () => Promise; } diff --git a/libs/common/src/vault/services/folder/folder-api.service.ts b/libs/common/src/vault/services/folder/folder-api.service.ts index c618c95872..e46df37c17 100644 --- a/libs/common/src/vault/services/folder/folder-api.service.ts +++ b/libs/common/src/vault/services/folder/folder-api.service.ts @@ -32,6 +32,11 @@ export class FolderApiService implements FolderApiServiceAbstraction { await this.folderService.delete(id); } + async deleteAll(): Promise { + await this.apiService.send("DELETE", "/folders/all", null, true, false); + await this.folderService.clear(); + } + async get(id: string): Promise { const r = await this.apiService.send("GET", "/folders/" + id, null, true, true); return new FolderResponse(r); From f70b3df2d2bff4a0aeb5a1f5940e2f4f894b5d26 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 12 Sep 2024 20:04:39 +0200 Subject: [PATCH 20/36] [PM-11949] Fix generating and copying export password (#10999) * Use password field value instead of local variable for copy to clipboard Use appCopyClick directive instead of manually copying and showing success toast * Add missing "copySuccessful" message key to desktop and web * Remove whitespace from web en/messages.json --------- Co-authored-by: Daniel James Smith --- apps/desktop/src/locales/en/messages.json | 3 +++ apps/web/src/locales/en/messages.json | 3 +++ .../src/components/export.component.html | 4 +++- .../src/components/export.component.ts | 16 +++------------- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 4ef70887a4..9194fd7c22 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1285,6 +1285,9 @@ } } }, + "copySuccessful": { + "message": "Copy Successful" + }, "errorRefreshingAccessToken": { "message": "Access Token Refresh Error" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c74fd3386a..45c2ac45b6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -562,6 +562,9 @@ } } }, + "copySuccessful": { + "message": "Copy Successful" + }, "copyValue": { "message": "Copy value", "description": "Copy value to clipboard" diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 626934b20e..7555b20697 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -87,7 +87,9 @@ [disabled]="!filePassword" appStopClick bitSuffix - (click)="copyPasswordToClipboard()" + [appCopyClick]="filePassword" + [valueLabel]="'password' | i18n" + showToast > {{ "exportPasswordDescription" | i18n }} diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index d83d189cd7..e4f5ec9d32 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -121,7 +121,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { encryptedExportType = EncryptedExportType; protected showFilePassword: boolean; - filePasswordValue: string = null; private _disabledByPolicy = false; organizations$: Observable; @@ -278,18 +277,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { generatePassword = async () => { const [options] = await this.passwordGenerationService.getOptions(); - this.filePasswordValue = await this.passwordGenerationService.generatePassword(options); - this.exportForm.get("filePassword").setValue(this.filePasswordValue); - this.exportForm.get("confirmFilePassword").setValue(this.filePasswordValue); - }; - - copyPasswordToClipboard = async () => { - this.platformUtilsService.copyToClipboard(this.filePasswordValue); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("valueCopied", this.i18nService.t("password")), - }); + const generatedPassword = await this.passwordGenerationService.generatePassword(options); + this.exportForm.get("filePassword").setValue(generatedPassword); + this.exportForm.get("confirmFilePassword").setValue(generatedPassword); }; submit = async () => { From fc2c83f0d35343d3c59fd7a736e1caf299d53f37 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:12:38 +0100 Subject: [PATCH 21/36] [AC-3022]Selecting a plan makes the plan cards content to auto-adjusts (#10992) * Resolve the recommended issue * Resolve the discount display issues * remove unused tw property * Resolve all the outstanding bugs * Fix the A11y bug * Resolve the base storage issue * Rename service account in the summary * changes for the A11y bug * Fix the improper keyboard navigation in modal * Add some additional ui changes --- .../change-plan-dialog.component.html | 266 ++++++++++++++---- .../change-plan-dialog.component.ts | 71 +++-- ...nization-subscription-cloud.component.html | 35 ++- ...ganization-subscription-cloud.component.ts | 13 + apps/web/src/locales/en/messages.json | 4 +- 5 files changed, 303 insertions(+), 86 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 14b6864d64..766646003b 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -7,31 +7,37 @@

{{ "upgradePlans" | i18n }}

{{ "selectAPlan" | i18n }} +
{{ "upgradeDiscount" | i18n - : (this.discountPercentageFromSub > 0 - ? discountPercentageFromSub - : this.discountPercentage) + : (selectedInterval === planIntervals.Annually + ? discountPercentageFromSub + this.discountPercentage + : this.discountPercentageFromSub) }} - +
{{ planInterval.name }} @@ -40,6 +46,7 @@
+
{{ "recommended" | i18n }}
-

+

{{ selectableProduct.nameLocalizationKey | i18n }} - + {{ "current" | i18n }}

@@ -133,10 +151,13 @@ else nonEnterprisePlans " > -

+

{{ "bitwardenPasswordManager" | i18n }}

-

{{ "enterprisePlanUpgradeMessage" | i18n }}

+

{{ "enterprisePlanUpgradeMessage" | i18n }}

  • @@ -157,7 +178,10 @@
-

+

{{ "bitwardenSecretsManager" | i18n }}

    @@ -195,25 +219,25 @@

    {{ "bitwardenPasswordManager" | i18n }}

    {{ "teamsPlanUpgradeMessage" | i18n }}

    {{ "familyPlanUpgradeMessage" | i18n }}

    • @@ -247,7 +271,7 @@

    {{ "secretsManagerSubInfo" | i18n }} - {{ "secretsManagerWithFreePasswordManagerInfo" | i18n }} + {{ "secretsManagerComplimentaryPasswordManager" | i18n }}
    @@ -392,23 +416,37 @@

    - {{ organization.maxStorageGb }} + {{ storageGb }} {{ "additionalStorageGbMessage" | i18n }} × {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} /{{ "year" | i18n }} - {{ - organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb - | currency: "$" - }} + {{ additionalStorageTotal(selectedPlan) | currency: "$" }} +

    + +

    + + + {{ + "providerDiscount" + | i18n: this.discountPercentageFromSub + this.discountPercentage + | lowercase + }} + + {{ + calculateTotalAppliedDiscount( + passwordManagerSeatTotal(selectedPlan) + additionalStorageTotal(selectedPlan) + ) | currency: "$" + }} +

    @@ -459,18 +497,40 @@ bitTypography="body2" *ngIf=" selectedPlan?.SecretsManager?.hasAdditionalServiceAccountOption && - additionalServiceAccount + additionalServiceAccount > 0 " > {{ additionalServiceAccount }} - {{ "additionalStorageGbMessage" | i18n }} + {{ "serviceAccounts" | i18n | lowercase }} × {{ selectedPlan?.SecretsManager?.additionalPricePerServiceAccount | currency: "$" }} /{{ "month" | i18n }} {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}

    + +

    + + + {{ + "providerDiscount" + | i18n: this.discountPercentageFromSub + this.discountPercentage + | lowercase + }} + + {{ + calculateTotalAppliedDiscount( + additionalServiceAccountTotal(selectedPlan) + + secretsManagerSeatTotal(selectedPlan, sub.smSeats) + ) | currency: "$" + }} + +

    @@ -512,24 +572,39 @@

    - {{ organization.maxStorageGb }} + {{ storageGb }} {{ "additionalStorageGbMessage" | i18n }} × {{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }} /{{ "month" | i18n }} {{ - organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb - | currency: "$" + storageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}

    + +

    + + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ calculateTotalAppliedDiscount(total) | currency: "$" }} + +

    {{ "secretsManager" | i18n }} @@ -575,18 +650,41 @@ bitTypography="body2" *ngIf=" selectedPlan.SecretsManager.hasAdditionalServiceAccountOption && - additionalServiceAccount + additionalServiceAccount > 0 " > {{ additionalServiceAccount }} - {{ "additionalStorageGbMessage" | i18n }} + {{ "serviceAccounts" | i18n | lowercase }} × {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} /{{ "month" | i18n }} {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}

    + +

    + + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ + additionalServiceAccountTotal(selectedPlan) + + secretsManagerSeatTotal(selectedPlan, sub?.smSeats) | currency: "$" + }} + +

@@ -641,18 +739,40 @@ bitTypography="body2" *ngIf=" selectedPlan.SecretsManager.hasAdditionalServiceAccountOption && - additionalServiceAccount + additionalServiceAccount > 0 " > {{ additionalServiceAccount }} - {{ "additionalStorageGbMessage" | i18n }} + {{ "serviceAccounts" | i18n }} × {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} /{{ "month" | i18n }} {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}

+ +

+ + + {{ + "providerDiscount" + | i18n: this.discountPercentageFromSub + this.discountPercentage + | lowercase + }} + + {{ + calculateTotalAppliedDiscount( + additionalServiceAccountTotal(selectedPlan) + + secretsManagerSeatTotal(selectedPlan, sub.smSeats) + ) | currency: "$" + }} + +

{{ "passwordManager" | i18n }} @@ -663,7 +783,7 @@ *ngIf="selectedPlan.PasswordManager.basePrice" > - {{ organization.seats }} + {{ sub?.seats }} {{ "members" | i18n }} × {{ (selectedPlan.isAnnual @@ -694,7 +814,7 @@ {{ "additionalUsers" | i18n }}: - {{ organization.seats || 0 }}  + {{ sub?.seats || 0 }}  {{ "members" | i18n }} × {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} @@ -756,12 +876,12 @@ bitTypography="body2" *ngIf=" selectedPlan.SecretsManager.hasAdditionalServiceAccountOption && - additionalServiceAccount + additionalServiceAccount > 0 " > {{ additionalServiceAccount }} - {{ "additionalStorageGbMessage" | i18n }} + {{ "serviceAccounts" | i18n }} × {{ selectedPlan.SecretsManager.additionalPricePerServiceAccount | currency: "$" }} /{{ "month" | i18n }} @@ -795,7 +915,7 @@ {{ "additionalUsers" | i18n }}: - {{ organization.seats }}  + {{ sub?.seats }}  {{ "members" | i18n }} × {{ selectedPlan.PasswordManager.seatPrice | currency: "$" }} @@ -811,6 +931,46 @@

+ +
+ +

+ + + {{ + "providerDiscount" + | i18n: this.discountPercentageFromSub + this.discountPercentage + | lowercase + }} + + {{ + calculateTotalAppliedDiscount(total) | currency: "$" + }} + + + + {{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }} + + {{ calculateTotalAppliedDiscount(total) | currency: "$" }} + +

+
+

{{ total | currency: "USD" : "$" }} - / {{ selectedPlanInterval | i18n }} + + / {{ selectedPlanInterval | i18n }}

diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 9a20fe38ef..dc9f6cce68 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -246,27 +246,28 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { selected: false, }, ]; - this.discountPercentageFromSub = this.sub?.customerDiscount?.percentOff; + this.discountPercentageFromSub = this.isSecretsManagerTrial() + ? 0 + : (this.sub?.customerDiscount?.percentOff ?? 0); this.setInitialPlanSelection(); this.loading = false; } setInitialPlanSelection() { - if ( - this.organization.useSecretsManager && - this.currentPlan.productTier == ProductTierType.Free - ) { - this.selectPlan(this.getPlanByType(ProductTierType.Teams)); - } else { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); - } + this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } getPlanByType(productTier: ProductTierType) { return this.selectableProducts.find((product) => product.productTier === productTier); } + secretsManagerTrialDiscount() { + return this.sub?.customerDiscount?.appliesTo?.includes("sm-standalone") + ? this.discountPercentage + : this.discountPercentageFromSub + this.discountPercentage; + } + isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -276,14 +277,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } planTypeChanged() { - if ( - this.organization.useSecretsManager && - this.currentPlan.productTier == ProductTierType.Free - ) { - this.selectPlan(this.getPlanByType(ProductTierType.Teams)); - } else { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); - } + this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } updateInterval(event: number) { @@ -304,6 +298,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ]; } + optimizedNgForRender(index: number) { + return index; + } + protected getPlanCardContainerClasses(plan: PlanResponse, index: number) { let cardState: PlanCardState; @@ -370,6 +368,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ) { return; } + + if (plan === this.currentPlan) { + return; + } this.selectedPlan = plan; this.formGroup.patchValue({ productTier: plan.productTier }); } @@ -463,6 +465,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return result; } + get storageGb() { + return this.sub?.maxStorageGb - 1; + } + passwordManagerSeatTotal(plan: PlanResponse): number { if (!plan.PasswordManager.hasAdditionalSeatsOption || this.isSecretsManagerTrial()) { return 0; @@ -486,8 +492,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } return ( - plan.PasswordManager.additionalStoragePricePerGb * - Math.abs(this.organization.maxStorageGb || 0) + plan.PasswordManager.additionalStoragePricePerGb * Math.abs(this.sub?.maxStorageGb - 1 || 0) ); } @@ -499,7 +504,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } additionalServiceAccountTotal(plan: PlanResponse): number { - if (!plan.SecretsManager.hasAdditionalServiceAccountOption || this.additionalServiceAccount) { + if ( + !plan.SecretsManager.hasAdditionalServiceAccountOption || + this.additionalServiceAccount == 0 + ) { return 0; } @@ -541,7 +549,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (this.selectedPlan.productTier === ProductTierType.Families) { return this.selectedPlan.PasswordManager.baseSeats; } - return this.organization.seats; + return this.sub?.seats; } get total() { @@ -565,7 +573,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get additionalServiceAccount() { - const baseServiceAccount = this.selectedPlan.SecretsManager?.baseServiceAccount || 0; + const baseServiceAccount = this.currentPlan.SecretsManager?.baseServiceAccount || 0; const usedServiceAccounts = this.sub?.smServiceAccounts || 0; const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts; @@ -652,7 +660,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (!this.acceptingSponsorship && !this.isInTrialFlow) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/organizations/" + orgId]); + this.router.navigate(["/organizations/" + orgId + "/members"]); } if (this.isInTrialFlow) { @@ -676,11 +684,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private async updateOrganization() { const request = new OrganizationUpgradeRequest(); if (this.selectedPlan.productTier !== ProductTierType.Families) { - request.additionalSeats = this.organization.seats; + request.additionalSeats = this.sub?.seats; } - if (this.organization.maxStorageGb > this.selectedPlan.PasswordManager.baseStorageGb) { + if (this.sub?.maxStorageGb > this.selectedPlan.PasswordManager.baseStorageGb) { request.additionalStorageGb = - this.organization.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb; + this.sub?.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb; } request.premiumAccessAddon = this.selectedPlan.PasswordManager.hasPremiumAccessOption && @@ -768,6 +776,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { request.additionalSmSeats = this.organization.seats; } else { request.additionalSmSeats = this.sub?.smSeats; + request.additionalServiceAccounts = this.additionalServiceAccount; } } @@ -812,6 +821,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.totalOpened = !this.totalOpened; } + calculateTotalAppliedDiscount(total: number) { + const discountPercent = + this.selectedInterval == PlanInterval.Annually + ? this.discountPercentage + this.discountPercentageFromSub + : this.discountPercentageFromSub; + + const discountedTotal = total / (1 - discountPercent / 100); + return discountedTotal; + } + get paymentSourceClasses() { if (this.billing.paymentSource == null) { return []; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 25c8c547b2..341324c4a2 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -69,14 +69,25 @@ >
- {{ - "details" | i18n - }} + {{ "details" | i18n + }}{{ "providerDiscount" | i18n: customerDiscount?.percentOff }} - + {{ i.productName | i18n }} - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ {{ i.amount | currency: "$" }} @@ -91,7 +102,19 @@ {{ "freeForOneYear" | i18n }} - {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} +
+ + {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} + + {{ + calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$" + }} + / {{ "year" | i18n }} +
@@ -112,7 +135,7 @@ -
+
-
- +
+ {{ + "number" | i18n + }}
@@ -38,26 +40,26 @@ height="32" />
-
- +
+ {{ + "expiration" | i18n + }}
-
-
- +
+ + {{ "securityCodeSlashCVV" | i18n }} -
+
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts index 03269c70a5..ab81a602d2 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ b/apps/web/src/app/billing/shared/payment/payment.component.ts @@ -5,15 +5,19 @@ import { Subject, takeUntil } from "rxjs"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SharedModule } from "../../../shared"; +import { PaymentLabelV2 } from "./payment-label-v2.component"; + @Component({ selector: "app-payment", templateUrl: "payment.component.html", standalone: true, - imports: [SharedModule], + imports: [SharedModule, PaymentLabelV2], }) export class PaymentComponent implements OnInit, OnDestroy { @Input() showMethods = true; @@ -63,14 +67,15 @@ export class PaymentComponent implements OnInit, OnDestroy { private apiService: ApiService, private logService: LogService, private themingService: AbstractThemingService, + private configService: ConfigService, ) { this.stripeScript = window.document.createElement("script"); this.stripeScript.src = "https://js.stripe.com/v3/?advancedFraudSignals=false"; this.stripeScript.async = true; - this.stripeScript.onload = () => { + this.stripeScript.onload = async () => { this.stripe = (window as any).Stripe(process.env.STRIPE_KEY); this.stripeElements = this.stripe.elements(); - this.setStripeElement(); + await this.setStripeElement(); }; this.btScript = window.document.createElement("script"); this.btScript.src = `scripts/dropin.js?cache=${process.env.CACHE_TAG}`; @@ -187,7 +192,7 @@ export class PaymentComponent implements OnInit, OnDestroy { ); }, 250); } else { - this.setStripeElement(); + void this.setStripeElement(); } } @@ -267,7 +272,17 @@ export class PaymentComponent implements OnInit, OnDestroy { }); } - private setStripeElement() { + private async setStripeElement() { + const extensionRefreshFlag = await this.configService.getFeatureFlag( + FeatureFlag.ExtensionRefresh, + ); + + // Apply unique styles for extension refresh + if (extensionRefreshFlag) { + this.StripeElementStyle.base.fontWeight = "500"; + this.StripeElementClasses.base = "v2"; + } + window.setTimeout(() => { if (this.showMethods && this.method === PaymentMethodType.Card) { if (this.stripeCardNumberElement == null) { diff --git a/apps/web/src/app/billing/shared/tax-info.component.html b/apps/web/src/app/billing/shared/tax-info.component.html index 89bc7438a7..82d5104a53 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.html +++ b/apps/web/src/app/billing/shared/tax-info.component.html @@ -1,6 +1,6 @@
-
+
{{ "country" | i18n }} @@ -13,7 +13,7 @@
-
+
{{ "zipPostalCode" | i18n }} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a9a709f3ec..38c58d9b8a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -143,6 +143,9 @@ "securityCode": { "message": "Security code (CVV)" }, + "securityCodeSlashCVV": { + "message": "Security code / CVV" + }, "identityName": { "message": "Identity name" }, diff --git a/apps/web/src/scss/forms.scss b/apps/web/src/scss/forms.scss index 9404bc9403..f5800ff7e5 100644 --- a/apps/web/src/scss/forms.scss +++ b/apps/web/src/scss/forms.scss @@ -98,7 +98,7 @@ input[type="checkbox"] { cursor: pointer; } -.form-control.stripe-form-control { +.form-control.stripe-form-control:not(.v2) { padding-top: 0.55rem; &.is-focused { @@ -126,6 +126,30 @@ input[type="checkbox"] { } } +.form-control.stripe-form-control.v2 { + padding: 0.6875rem 0.875rem; + border-radius: 0.5rem; + border-color: rgb(var(--color-text-muted)); + height: unset; + font-weight: 500; + color: rgb(var(--color-text-main)); + background-color: rgb(var(--color-background)); + + &:hover { + border-color: rgb(var(--color-primary-500)); + } + + &.is-focused { + outline: 0; + border-color: rgb(var(--color-primary-500)); + } + + &.is-invalid { + color: rgb(var(--color-text-main)); + border-color: rgb(var(--color-danger-600)); + } +} + .dropdown-menu, .dropdown-item { @include themify($themes) { From a31ecb18a185237a575cbf2d9d9c4efd1afa4445 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:47:23 -0500 Subject: [PATCH 24/36] [PM-11267] Extension Refresh Events (#10832) * add `Cipher_ClientViewed` event to browser refresh components * add `Cipher_ClientToggledPasswordVisible` event to browser refresh components * add `Cipher_ClientToggledHiddenFieldVisible` event to browser refresh components * add `Cipher_ClientToggledCardCodeVisible` event to browser refresh components * add `Cipher_ClientToggledCardNumberVisible` event to browser refresh components * add `Cipher_ClientToggledTOTPSeedVisible` event to browser refresh components * add `Cipher_ClientCopiedPassword` event to browser refresh components * add events for copying cipher fields from the vault menu * add `Cipher_ClientCopiedHiddenField` event to browser refresh components * add mock for tests * add missing `Cipher_ClientCopiedCardCode` event * remove the need for separate fields input * add organization id to event collection so events are collected * update test * add event collection service to cipher form stories --- libs/vault/src/cipher-form/cipher-form.stories.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index c78bd30873..96d4289028 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -10,6 +10,7 @@ import { import { BehaviorSubject } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -168,6 +169,12 @@ export default { autofillOnPageLoadDefault$: new BehaviorSubject(true), }, }, + { + provide: EventCollectionService, + useValue: { + collect: () => Promise.resolve(), + }, + }, ], }), componentWrapperDecorator( From fe96aa85f2f38942a15bedf15c281e673819c13c Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:59:33 -0400 Subject: [PATCH 25/36] [PM-12007] Fix vault timeout action logout with account switching (#11008) * Protect Against Toast Error * Use `concatMap` Instead of `switchMap` --- apps/browser/src/popup/app.component.ts | 7 ++++++- .../src/services/vault-timeout/vault-timeout.service.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 84a9ee264c..477152fff8 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -247,7 +247,7 @@ export class AppComponent implements OnInit, OnDestroy { // Displaying toasts isn't super useful on the popup due to the reloads we do. // However, it is visible for a moment on the FF sidebar logout. private async displayLogoutReason(logoutReason: LogoutReason) { - let toastOptions: ToastOptions; + let toastOptions: ToastOptions | null = null; switch (logoutReason) { case "invalidSecurityStamp": case "sessionExpired": { @@ -260,6 +260,11 @@ export class AppComponent implements OnInit, OnDestroy { } } + if (toastOptions == null) { + // We don't have anything to show for this particular reason + return; + } + this.toastService.showToast(toastOptions); } } diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index d9efef44f4..c40e4687b7 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -1,4 +1,4 @@ -import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs"; +import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -79,7 +79,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { this.accountService.activeAccount$, this.accountService.accountActivity$, ]).pipe( - switchMap(async ([activeAccount, accountActivity]) => { + concatMap(async ([activeAccount, accountActivity]) => { const activeUserId = activeAccount?.id; for (const userIdString in accountActivity) { const userId = userIdString as UserId; From bd3863c313602c521f537f5fabd8f1c8cc41204f Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 12 Sep 2024 20:14:37 -0400 Subject: [PATCH 26/36] Auth/PM-11969 - Registration with Email Verification - Accept Emergency Access Invite Flow (#11018) * PM-11969 - Registration with Email Verification - Accept Emergency Access Invite Fixed * PM-11969 - Fix PR feedback * PM-11969 - AcceptEmergencyComponent - remove prop --- .../web-registration-finish.service.ts | 7 ++++ .../accept/accept-emergency.component.html | 10 ++---- .../accept/accept-emergency.component.ts | 34 +++++++++++++++++++ .../default-registration-finish.service.ts | 6 ++++ .../registration-finish.component.ts | 11 ++++++ .../registration-finish.service.ts | 4 +++ .../registration/register-finish.request.ts | 2 ++ 7 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts index cf54c5a535..5239601bbc 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -71,6 +71,8 @@ export class WebRegistrationFinishService userAsymmetricKeys: [string, EncString], emailVerificationToken?: string, orgSponsoredFreeFamilyPlanToken?: string, + acceptEmergencyAccessInviteToken?: string, + emergencyAccessId?: string, ): Promise { const registerRequest = await super.buildRegisterRequest( email, @@ -94,6 +96,11 @@ export class WebRegistrationFinishService registerRequest.orgSponsoredFreeFamilyPlanToken = orgSponsoredFreeFamilyPlanToken; } + if (acceptEmergencyAccessInviteToken && emergencyAccessId) { + registerRequest.acceptEmergencyAccessInviteToken = acceptEmergencyAccessInviteToken; + registerRequest.acceptEmergencyAccessId = emergencyAccessId; + } + return registerRequest; } } diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html index 315df6f2c8..3fa795db15 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html @@ -26,14 +26,8 @@ > {{ "logIn" | i18n }} - +
diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts index d5ca41c42c..cd11bc72f3 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts @@ -1,5 +1,6 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { RegisterRouteService } from "@bitwarden/auth/common"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -18,6 +19,8 @@ import { EmergencyAccessService } from "../services/emergency-access.service"; }) export class AcceptEmergencyComponent extends BaseAcceptComponent { name: string; + emergencyAccessId: string; + acceptEmergencyAccessInviteToken: string; protected requiredParameters: string[] = ["id", "name", "email", "token"]; protected failedShortMessage = "emergencyInviteAcceptFailedShort"; @@ -55,5 +58,36 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent { // Fix URL encoding of space issue with Angular this.name = this.name.replace(/\+/g, " "); } + + if (qParams.id) { + this.emergencyAccessId = qParams.id; + } + + if (qParams.token) { + this.acceptEmergencyAccessInviteToken = qParams.token; + } + } + + async register() { + let queryParams: Params; + let registerRoute = await firstValueFrom(this.registerRoute$); + if (registerRoute === "/register") { + queryParams = { + email: this.email, + }; + } else if (registerRoute === "/signup") { + // We have to override the base component route as we don't need users to + // complete email verification if they are coming directly an emailed invite. + registerRoute = "/finish-signup"; + queryParams = { + email: this.email, + acceptEmergencyAccessInviteToken: this.acceptEmergencyAccessInviteToken, + emergencyAccessId: this.emergencyAccessId, + }; + } + + await this.router.navigate([registerRoute], { + queryParams: queryParams, + }); } } diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts index 882dd7b76c..63b01be995 100644 --- a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts @@ -24,6 +24,8 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi passwordInputResult: PasswordInputResult, emailVerificationToken?: string, orgSponsoredFreeFamilyPlanToken?: string, + acceptEmergencyAccessInviteToken?: string, + emergencyAccessId?: string, ): Promise { const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey( passwordInputResult.masterKey, @@ -41,6 +43,8 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi userAsymmetricKeys, emailVerificationToken, orgSponsoredFreeFamilyPlanToken, + acceptEmergencyAccessInviteToken, + emergencyAccessId, ); const capchaBypassToken = await this.accountApiService.registerFinish(registerRequest); @@ -55,6 +59,8 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi userAsymmetricKeys: [string, EncString], emailVerificationToken?: string, orgSponsoredFreeFamilyPlanToken?: string, // web only + acceptEmergencyAccessInviteToken?: string, // web only + emergencyAccessId?: string, // web only ): Promise { const userAsymmetricKeysRequest = new KeysRequest( userAsymmetricKeys[0], diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index e9703c6085..ef40d95dce 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -44,6 +44,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { // setup a free family plan sponsored by an organization but they don't have an account yet. orgSponsoredFreeFamilyPlanToken: string; + // this token is provided when the user is coming from an emailed invite to accept an emergency access invite + acceptEmergencyAccessInviteToken: string; + emergencyAccessId: string; + masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; constructor( @@ -79,6 +83,11 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { if (qParams.orgSponsoredFreeFamilyPlanToken != null) { this.orgSponsoredFreeFamilyPlanToken = qParams.orgSponsoredFreeFamilyPlanToken; } + + if (qParams.acceptEmergencyAccessInviteToken != null && qParams.emergencyAccessId) { + this.acceptEmergencyAccessInviteToken = qParams.acceptEmergencyAccessInviteToken; + this.emergencyAccessId = qParams.emergencyAccessId; + } }), switchMap((qParams: Params) => { if ( @@ -111,6 +120,8 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { passwordInputResult, this.emailVerificationToken, this.orgSponsoredFreeFamilyPlanToken, + this.acceptEmergencyAccessInviteToken, + this.emergencyAccessId, ); } catch (e) { this.validationService.showError(e); diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts index 0c674d566c..b585aa78ed 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts @@ -16,6 +16,8 @@ export abstract class RegistrationFinishService { * @param passwordInputResult The password input result. * @param emailVerificationToken The optional email verification token. Not present in emailed invite scenarios (ex: org invite). * @param orgSponsoredFreeFamilyPlanToken The optional org sponsored free family plan token. + * @param acceptEmergencyAccessInviteToken The optional accept emergency access invite token. + * @param emergencyAccessId The optional emergency access id which is required to validate the emergency access invite token. * Returns a promise which resolves to the captcha bypass token string upon a successful account creation. */ abstract finishRegistration( @@ -23,5 +25,7 @@ export abstract class RegistrationFinishService { passwordInputResult: PasswordInputResult, emailVerificationToken?: string, orgSponsoredFreeFamilyPlanToken?: string, + acceptEmergencyAccessInviteToken?: string, + emergencyAccessId?: string, ): Promise; } diff --git a/libs/common/src/auth/models/request/registration/register-finish.request.ts b/libs/common/src/auth/models/request/registration/register-finish.request.ts index 5e412aa4e6..6a36bf8213 100644 --- a/libs/common/src/auth/models/request/registration/register-finish.request.ts +++ b/libs/common/src/auth/models/request/registration/register-finish.request.ts @@ -19,6 +19,8 @@ export class RegisterFinishRequest { public emailVerificationToken?: string, public orgSponsoredFreeFamilyPlanToken?: string, + public acceptEmergencyAccessInviteToken?: string, + public acceptEmergencyAccessId?: string, // Org Invite data (only applies on web) public organizationUserId?: string, From 023912c53dc53c95d13a93ef0cc2497f55743e2b Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:58:18 -0700 Subject: [PATCH 27/36] [PM-11631] - hide free Bitwarden Families button if user is not eligible (#10971) * hide Free Bitwarden Families button if user is not eligible * use organizationService.canManageSponsorships$ for determining if free bitwarden families is available * update comment --- .../about-page/more-from-bitwarden-page-v2.component.html | 2 +- .../about-page/more-from-bitwarden-page-v2.component.ts | 4 ++++ .../organization/organization.service.abstraction.ts | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html index affe9ffc04..9322ab5113 100644 --- a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html +++ b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html @@ -12,7 +12,7 @@ - +

{{ "paymentType" | i18n }}

- + +
diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index 7dad7effee..9b9e6f0cd0 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -11,12 +11,14 @@ import { } from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../shared"; +import { PaymentV2Component } from "../../shared/payment/payment-v2.component"; export type TrialOrganizationType = Exclude; @@ -49,6 +51,7 @@ export enum SubscriptionProduct { }) export class TrialBillingStepComponent implements OnInit { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; + @ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component; @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; @Input() organizationInfo: OrganizationInfo; @Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager; @@ -69,17 +72,22 @@ export class TrialBillingStepComponent implements OnInit { annualPlan?: PlanResponse; monthlyPlan?: PlanResponse; + deprecateStripeSourcesAPI: boolean; + constructor( private apiService: ApiService, + private configService: ConfigService, private i18nService: I18nService, private formBuilder: FormBuilder, private messagingService: MessagingService, private organizationBillingService: OrganizationBillingService, - private platformUtilsService: PlatformUtilsService, private toastService: ToastService, ) {} async ngOnInit(): Promise { + this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag( + FeatureFlag.AC2476_DeprecateStripeSourcesAPI, + ); const plans = await this.apiService.getPlans(); this.applicablePlans = plans.data.filter(this.isApplicable); this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual); @@ -114,13 +122,23 @@ export class TrialBillingStepComponent implements OnInit { } protected changedCountry() { - this.paymentComponent.hideBank = this.taxInfoComponent.taxFormGroup.value.country !== "US"; - if ( - this.paymentComponent.hideBank && - this.paymentComponent.method === PaymentMethodType.BankAccount - ) { - this.paymentComponent.method = PaymentMethodType.Card; - this.paymentComponent.changeMethod(); + if (this.deprecateStripeSourcesAPI) { + this.paymentV2Component.showBankAccount = this.taxInfoComponent.country === "US"; + if ( + !this.paymentV2Component.showBankAccount && + this.paymentV2Component.selected === PaymentMethodType.BankAccount + ) { + this.paymentV2Component.select(PaymentMethodType.Card); + } + } else { + this.paymentComponent.hideBank = this.taxInfoComponent.taxFormGroup.value.country !== "US"; + if ( + this.paymentComponent.hideBank && + this.paymentComponent.method === PaymentMethodType.BankAccount + ) { + this.paymentComponent.method = PaymentMethodType.Card; + this.paymentComponent.changeMethod(); + } } } @@ -141,7 +159,15 @@ export class TrialBillingStepComponent implements OnInit { private async createOrganization(): Promise { const planResponse = this.findPlanFor(this.formGroup.value.cadence); - const paymentMethod = await this.paymentComponent.createPaymentToken(); + + let paymentMethod: [string, PaymentMethodType]; + + if (this.deprecateStripeSourcesAPI) { + const { type, token } = await this.paymentV2Component.tokenize(); + paymentMethod = [token, type]; + } else { + paymentMethod = await this.paymentComponent.createPaymentToken(); + } const organization: OrganizationInformation = { name: this.organizationInfo.name, From 54cc35e29a97c93f0f31ad92fe29f2fd4cb8a900 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 13 Sep 2024 18:11:05 +0200 Subject: [PATCH 35/36] [PM-6037] Fix process reload not triggering on inactive account lock/logout (#9805) * Send loggedOut/locked events on logout/lock event * Revert "Send loggedOut/locked events on logout/lock event" This reverts commit 293f2d613171ce9f9d52db11d18e511a16dd54e0. * Ensure loggedOut is sent for non-active user logouts too * Make loggedOut accept userIds * Add userBeingLoggedOut in desktop app component * Await updateconnection calls --- apps/desktop/src/app/app.component.ts | 10 ++++------ apps/web/src/app/app.component.ts | 11 +++++++---- libs/common/src/auth/abstractions/auth.service.ts | 2 +- libs/common/src/auth/services/auth.service.ts | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 089eb1c027..b1f50a7b75 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -194,9 +194,9 @@ export class AppComponent implements OnInit, OnDestroy { break; case "loggedOut": this.modalService.closeAll(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.notificationsService.updateConnection(); + if (message.userId == null || message.userId === this.activeUserId) { + await this.notificationsService.updateConnection(); + } // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAppMenu(); @@ -694,9 +694,7 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up - if (userBeingLoggedOut === activeUserId) { - this.authService.logOut(async () => {}); - } + this.authService.logOut(async () => {}, userBeingLoggedOut); } private async recordActivity() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 828fe8ea3f..ef6cbd2804 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -119,9 +119,12 @@ export class AppComponent implements OnDestroy, OnInit { this.notificationsService.updateConnection(false); break; case "loggedOut": - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.notificationsService.updateConnection(false); + if ( + message.userId == null || + message.userId === (await firstValueFrom(this.accountService.activeAccount$)) + ) { + await this.notificationsService.updateConnection(false); + } break; case "unlocked": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -311,7 +314,7 @@ export class AppComponent implements OnDestroy, OnInit { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/"]); } - }); + }, userId); } private async recordActivity() { diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index 36d5d219b2..df408e76f8 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -16,5 +16,5 @@ export abstract class AuthService { abstract authStatusFor$(userId: UserId): Observable; /** @deprecated use {@link activeAccountStatus$} instead */ abstract getAuthStatus: (userId?: string) => Promise; - abstract logOut: (callback: () => void) => void; + abstract logOut: (callback: () => void, userId?: string) => void; } diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 25e7b92edf..307da55a5e 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -93,8 +93,8 @@ export class AuthService implements AuthServiceAbstraction { return await firstValueFrom(this.authStatusFor$(userId as UserId)); } - logOut(callback: () => void) { + logOut(callback: () => void, userId?: string): void { callback(); - this.messageSender.send("loggedOut"); + this.messageSender.send("loggedOut", { userId }); } } From 96d116d643ce825477e9e88a223c246058c0c36e Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:16:25 -0700 Subject: [PATCH 36/36] [PM-8116] Auth Browser Refresh: Password Hint Component (#10492) * setup component, services, and web HTML * make Web and Browser functional * make desktop functional * update template to solidify common client HTML * simplify template and class * update browser routing * move canActivate to correct location * simplify post submit routing * update routing to use unauthUiRefreshSwap() * constrain AnonLayout title/subtitle width, reduce height on destkop to account for header * reduce height on browser to account for header (otherwise have to scroll to see EnvSelector * resolve email issue when clicking 'cancel' on extension popout * update routing for web * persist email to popout * update web router and anon-layout min-h based on client * change anchor link to button * remove unnecessary formatting changes * add new icon * remove unnecessary call to loginEmailService --- apps/browser/src/_locales/en/messages.json | 12 ++ apps/browser/src/popup/app-routing.module.ts | 46 +++++++- apps/browser/src/popup/app.module.ts | 2 + apps/desktop/src/app/app-routing.module.ts | 48 +++++++- apps/desktop/src/locales/en/messages.json | 12 ++ apps/web/src/app/oss-routing.module.ts | 67 +++++++---- apps/web/src/locales/en/messages.json | 12 ++ .../src/auth/components/login.component.ts | 1 + .../functions/unauth-ui-refresh-route-swap.ts | 2 + .../anon-layout/anon-layout.component.html | 8 +- libs/auth/src/angular/icons/index.ts | 1 + libs/auth/src/angular/icons/user-lock.icon.ts | 22 ++++ libs/auth/src/angular/index.ts | 7 +- .../password-hint.component.html | 40 +++++++ .../password-hint/password-hint.component.ts | 107 ++++++++++++++++++ 15 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 libs/auth/src/angular/icons/user-lock.icon.ts create mode 100644 libs/auth/src/angular/password-hint/password-hint.component.html create mode 100644 libs/auth/src/angular/password-hint/password-hint.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 47e89dcb44..b8d3a2d47c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -179,6 +179,18 @@ "addItem": { "message": "Add item" }, + "accountEmail": { + "message": "Account email" + }, + "requestHint": { + "message": "Request hint" + }, + "requestPasswordHint": { + "message": "Request password hint" + }, + "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { + "message": "Enter your account email address and your password hint will be sent to you" + }, "passwordHint": { "message": "Password hint" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index f715d38422..0f6a9d9248 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -1,6 +1,8 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; +import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, lockGuard, @@ -15,11 +17,13 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, SetPasswordJitComponent, + UserLockIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -27,6 +31,7 @@ import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-fa import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; +import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; @@ -213,12 +218,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { state: "register" }, }, - { - path: "hint", - component: HintComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "hint" }, - }, { path: "environment", component: EnvironmentComponent, @@ -385,6 +384,41 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "update-temp-password" }, }, + ...unauthUiRefreshSwap( + HintComponent, + ExtensionAnonLayoutWrapperComponent, + { + path: "hint", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + state: "hint", + }, + }, + { + path: "", + children: [ + { + path: "hint", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageTitle: "requestPasswordHint", + pageSubtitle: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + pageIcon: UserLockIcon, + showBackButton: true, + state: "hint", + }, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ), { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index f8d3c69105..f14dafacb7 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -20,6 +20,7 @@ import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components"; import { AccountComponent } from "../auth/popup/account-switching/account.component"; import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; +import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; @@ -131,6 +132,7 @@ import "../platform/popup/locales"; HeaderComponent, UserVerificationDialogComponent, CurrentAccountComponent, + ExtensionAnonLayoutWrapperComponent, ], declarations: [ ActionButtonsComponent, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 2376eb3844..2e44d2213e 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -1,6 +1,8 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, lockGuard, @@ -12,11 +14,13 @@ import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, SetPasswordJitComponent, + UserLockIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -94,7 +98,6 @@ const routes: Routes = [ canActivate: [authGuard], }, { path: "accessibility-cookie", component: AccessibilityCookieComponent }, - { path: "hint", component: HintComponent }, { path: "set-password", component: SetPasswordComponent }, { path: "sso", component: SsoComponent }, { @@ -113,10 +116,53 @@ const routes: Routes = [ canActivate: [authGuard], data: { titleId: "removeMasterPassword" }, }, + ...unauthUiRefreshSwap( + HintComponent, + AnonLayoutWrapperComponent, + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: "passwordHint", + titleId: "passwordHint", + }, + }, + { + path: "", + children: [ + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: "requestPasswordHint", + pageSubtitle: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + pageIcon: UserLockIcon, + state: "hint", + }, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ), { path: "", component: AnonLayoutWrapperComponent, children: [ + { + path: "hint", + component: PasswordHintComponent, + data: { + pageTitle: "requestPasswordHint", + pageSubtitle: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + }, { path: "signup", canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 5991fc4d06..721faa2567 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -560,6 +560,18 @@ "settings": { "message": "Settings" }, + "accountEmail": { + "message": "Account email" + }, + "requestHint": { + "message": "Request hint" + }, + "requestPasswordHint": { + "message": "Request password hint" + }, + "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { + "message": "Enter your account email address and your password hint will be sent to you" + }, "passwordHint": { "message": "Password hint" }, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index de0e8a2da9..b8d1502b6b 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; +import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, lockGuard, @@ -12,13 +13,15 @@ import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, SetPasswordJitComponent, - LockIcon, RegistrationLinkExpiredComponent, + LockIcon, + UserLockIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -167,6 +170,49 @@ const routes: Routes = [ }, ], }, + ...unauthUiRefreshSwap( + AnonLayoutWrapperComponent, + AnonLayoutWrapperComponent, + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: "passwordHint", + titleId: "passwordHint", + }, + children: [ + { path: "", component: HintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "", + children: [ + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: "requestPasswordHint", + pageSubtitle: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + pageIcon: UserLockIcon, + state: "hint", + }, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ), { path: "", component: AnonLayoutWrapperComponent, @@ -388,25 +434,6 @@ const routes: Routes = [ }, ], }, - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: "passwordHint", - titleId: "passwordHint", - } satisfies DataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: HintComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, { path: "remove-password", component: RemovePasswordComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4a189a54d6..d1d10dc967 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -960,6 +960,18 @@ "settings": { "message": "Settings" }, + "accountEmail": { + "message": "Account email" + }, + "requestHint": { + "message": "Request hint" + }, + "requestPasswordHint": { + "message": "Request password hint" + }, + "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { + "message": "Enter your account email address and your password hint will be sent to you" + }, "passwordHint": { "message": "Password hint" }, diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index b798a8df0b..831d505a38 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -317,6 +317,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, // Try to load from memory first const email = await firstValueFrom(this.loginEmailService.loginEmail$); const rememberEmail = this.loginEmailService.getRememberEmail(); + if (email) { this.formGroup.controls.email.setValue(email); this.formGroup.controls.rememberEmail.setValue(rememberEmail); diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts index 45dad4a1a7..1146b7b40e 100644 --- a/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts +++ b/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts @@ -20,6 +20,7 @@ export function unauthUiRefreshSwap( defaultComponent: Type, refreshedComponent: Type, options: Route, + altOptions?: Route, ): Routes { return componentRouteSwap( defaultComponent, @@ -29,5 +30,6 @@ export function unauthUiRefreshSwap( return configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh); }, options, + altOptions, ); } diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 6603fa970d..9e6c27f601 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -1,9 +1,12 @@ +
@@ -23,6 +26,7 @@ {{ title }}

+
{{ subtitle }}
diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index f502fbe5f3..cfcad992e3 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -1,4 +1,5 @@ export * from "./bitwarden-logo.icon"; export * from "./bitwarden-shield.icon"; export * from "./lock.icon"; +export * from "./user-lock.icon"; export * from "./user-verification-biometrics-fingerprint.icon"; diff --git a/libs/auth/src/angular/icons/user-lock.icon.ts b/libs/auth/src/angular/icons/user-lock.icon.ts new file mode 100644 index 0000000000..fef00a09a9 --- /dev/null +++ b/libs/auth/src/angular/icons/user-lock.icon.ts @@ -0,0 +1,22 @@ +import { svgIcon } from "@bitwarden/components"; + +export const UserLockIcon = svgIcon` + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index f6a9ffde55..bfb3a67aed 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -16,7 +16,9 @@ export * from "./fingerprint-dialog/fingerprint-dialog.component"; // password callout export * from "./password-callout/password-callout.component"; -export * from "./vault-timeout-input/vault-timeout-input.component"; + +// password hint +export * from "./password-hint/password-hint.component"; // input password export * from "./input-password/input-password.component"; @@ -40,3 +42,6 @@ export * from "./registration/registration-start/registration-start-secondary.co export * from "./registration/registration-env-selector/registration-env-selector.component"; export * from "./registration/registration-finish/registration-finish.service"; export * from "./registration/registration-finish/default-registration-finish.service"; + +// vault timeout +export * from "./vault-timeout-input/vault-timeout-input.component"; diff --git a/libs/auth/src/angular/password-hint/password-hint.component.html b/libs/auth/src/angular/password-hint/password-hint.component.html new file mode 100644 index 0000000000..2a811a1b3b --- /dev/null +++ b/libs/auth/src/angular/password-hint/password-hint.component.html @@ -0,0 +1,40 @@ + + + + + {{ "accountEmail" | i18n }} + + + + + + + + +
+ +
+ + + + + + diff --git a/libs/auth/src/angular/password-hint/password-hint.component.ts b/libs/auth/src/angular/password-hint/password-hint.component.ts new file mode 100644 index 0000000000..1ae1fd337b --- /dev/null +++ b/libs/auth/src/angular/password-hint/password-hint.component.ts @@ -0,0 +1,107 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Router, RouterModule } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PasswordHintRequest } from "@bitwarden/common/auth/models/request/password-hint.request"; +import { ClientType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + ToastService, +} from "@bitwarden/components"; + +@Component({ + standalone: true, + templateUrl: "./password-hint.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CommonModule, + FormFieldModule, + JslibModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class PasswordHintComponent implements OnInit { + protected clientType: ClientType; + + protected formGroup = this.formBuilder.group({ + email: ["", [Validators.required, Validators.email]], + }); + + protected get email() { + return this.formGroup.controls.email.value; + } + + constructor( + private apiService: ApiService, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private loginEmailService: LoginEmailServiceAbstraction, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + private router: Router, + ) { + this.clientType = this.platformUtilsService.getClientType(); + } + + async ngOnInit(): Promise { + const email = (await firstValueFrom(this.loginEmailService.loginEmail$)) ?? ""; + this.formGroup.controls.email.setValue(email); + } + + submit = async () => { + const isEmailValid = this.validateEmailOrShowToast(this.email); + if (!isEmailValid) { + return; + } + + await this.apiService.postPasswordHint(new PasswordHintRequest(this.email)); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("masterPassSent"), + }); + + await this.router.navigate(["login"]); + }; + + protected async cancel() { + this.loginEmailService.setLoginEmail(this.email); + await this.router.navigate(["login"]); + } + + private validateEmailOrShowToast(email: string): boolean { + // If email is null or empty, show error toast and return false + if (email == null || email === "") { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("emailRequired"), + }); + return false; + } + + // If not a valid email format, show error toast and return false + if (email.indexOf("@") === -1) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidEmail"), + }); + return false; + } + + return true; // email is valid + } +}