From ca3e9fc1bcf69a289e6168bc8e280d098d8ba348 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Mon, 21 Oct 2024 17:56:50 +0200 Subject: [PATCH 01/41] =?UTF-8?q?[PM-12527]=20[Defect]=20Remove=20unnecess?= =?UTF-8?q?ary=20"General=20Information"=20Header=20in=20Organizat?= =?UTF-8?q?=E2=80=A6=20(#11198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/billing/organizations/organization-plans.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index e6e2610d67c..16c5259e8ac 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -46,7 +46,7 @@ *ngIf="!loading && !selfHosted && this.passwordManagerPlans && this.secretsManagerPlans" class="tw-pt-6" > - + Date: Mon, 21 Oct 2024 12:07:28 -0400 Subject: [PATCH 02/41] [PM-13402] Adding the service to get member cipher details (#11544) * Adding the service to get member cipher details * Moving member cipher details to bitwarden license * Adding documentation to the api call --- .../reports/access-intelligence/index.ts | 0 .../member-cipher-details.response.ts | 16 +++ .../member-cipher-details-api.service.spec.ts | 105 ++++++++++++++++++ .../member-cipher-details-api.service.ts | 27 +++++ 4 files changed, 148 insertions(+) create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts new file mode 100644 index 00000000000..fcf5ada4b2c --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class MemberCipherDetailsResponse extends BaseResponse { + userName: string; + email: string; + useKeyConnector: boolean; + cipherIds: string[] = []; + + constructor(response: any) { + super(response); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.useKeyConnector = this.getResponseProperty("UseKeyConnector"); + this.cipherIds = this.getResponseProperty("CipherIds"); + } +} diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts new file mode 100644 index 00000000000..b71abe075e6 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts @@ -0,0 +1,105 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; + +const mockMemberCipherDetails: any = { + data: [ + { + userName: "David Brent", + email: "david.brent@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Tim Canterbury", + email: "tim.canterbury@wernhamhogg.uk", + usesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Gareth Keenan", + email: "gareth.keenan@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + "cbea34a8-bde4-46ad-9d19-b05001227nm7", + ], + }, + { + userName: "Dawn Tinsley", + email: "dawn.tinsley@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + { + userName: "Keith Bishop", + email: "keith.bishop@wernhamhogg.uk", + usesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Chris Finch", + email: "chris.finch@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + ], +}; + +describe("Member Cipher Details API Service", () => { + let memberCipherDetailsApiService: MemberCipherDetailsApiService; + + const apiService = mock(); + + beforeEach(() => { + memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService); + jest.resetAllMocks(); + }); + + it("instantiates", () => { + expect(memberCipherDetailsApiService).not.toBeFalsy(); + }); + + it("getMemberCipherDetails retrieves data", async () => { + apiService.send.mockResolvedValue(mockMemberCipherDetails); + + const orgId = "1234"; + const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); + expect(result).not.toBeNull(); + expect(result).toHaveLength(6); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/reports/member-cipher-details/" + orgId, + null, + true, + true, + ); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts new file mode 100644 index 00000000000..9351ac87776 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts @@ -0,0 +1,27 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; + +export class MemberCipherDetailsApiService { + constructor(private apiService: ApiService) {} + + /** + * Returns a list of organization members with their assigned + * cipherIds + * @param orgId OrganizationId to get member cipher details for + * @returns List of organization members and assigned cipherIds + */ + async getMemberCipherDetails(orgId: string): Promise { + const response = await this.apiService.send( + "GET", + "/reports/member-cipher-details/" + orgId, + null, + true, + true, + ); + + const listResponse = new ListResponse(response, MemberCipherDetailsResponse); + return listResponse.data.map((r) => new MemberCipherDetailsResponse(r)); + } +} From 77c50860a9d6a2ece901d97488985208f0d6a2e8 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:30:25 -0400 Subject: [PATCH 03/41] [PM-12290] Show self-host options for CB MSP managed orgs (#11465) * (No Logic) organization-subscription-cloud.component.ts cleanup * Show only selfhost options for org owners and provider admins for managed orgs * Fix messages.json issue --- .../icons/manage-billing.icon.ts | 25 ---- .../icons/subscription-hidden.icon.ts | 24 ++++ ...nization-subscription-cloud.component.html | 111 +++++++++-------- ...ganization-subscription-cloud.component.ts | 112 +++++++----------- apps/web/src/locales/en/messages.json | 10 ++ .../organization-billing-metadata.response.ts | 2 + 6 files changed, 131 insertions(+), 153 deletions(-) delete mode 100644 apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts create mode 100644 apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts diff --git a/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts b/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts deleted file mode 100644 index 6f583bf2e81..00000000000 --- a/apps/web/src/app/billing/organizations/icons/manage-billing.icon.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const ManageBilling = svgIcon` - - - - - - - - - - - - - - - - - - - - - - `; diff --git a/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts b/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts new file mode 100644 index 00000000000..82490e82a1d --- /dev/null +++ b/apps/web/src/app/billing/organizations/icons/subscription-hidden.icon.ts @@ -0,0 +1,24 @@ +import { Icon, svgIcon } from "@bitwarden/components"; + +export const SubscriptionHiddenIcon: Icon = svgIcon` + + + + + + + + + + + + + + + + + + + + +`; 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 643eeb93bad..0a83b2a56ce 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 @@ -1,17 +1,12 @@ - - - - {{ "loading" | i18n }} - + + + {{ "loading" | i18n }} + - - - + + - -

- {{ "selfHostingTitle" | i18n }} -

-

- {{ "selfHostingEnterpriseOrganizationSectionCopy" | i18n }} - - -

-
- - -
+

{{ "additionalOptions" | i18n }}

@@ -302,13 +262,50 @@ - -

- - {{ - "manageBillingFromProviderPortalMessage" | i18n - }} -
-
+ + + +

{{ "manageSubscription" | i18n }}

+

+ {{ "manageSubscriptionFromThe" | i18n }} + {{ + "providerPortal" | i18n + }}. +

+ +

+ {{ "billingManagedByProvider" | i18n: userOrg.providerName }}. + {{ "billingContactProviderForAssistance" | i18n }}. +

+
+ +
+ +
+ +

{{ "billingManagedByProvider" | i18n: userOrg.providerName }}

+

{{ "billingContactProviderForAssistance" | i18n }}

+
+
+
+
+ + + +

+ {{ "selfHostingTitleProper" | i18n }} +

+

+ {{ "toHostBitwardenOnYourOwnServer" | i18n }} +

+
+ + +
+
+
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 7a66faa0a43..e604140e569 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -5,9 +5,9 @@ import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUnti import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { OrganizationApiKeyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; @@ -15,7 +15,6 @@ 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"; import { DialogService, ToastService } from "@bitwarden/components"; import { @@ -34,7 +33,7 @@ import { import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan-dialog.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; -import { ManageBilling } from "./icons/manage-billing.icon"; +import { SubscriptionHiddenIcon } from "./icons/subscription-hidden.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @Component({ @@ -50,19 +49,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy hasBillingSyncToken: boolean; showAdjustSecretsManager = false; showSecretsManagerSubscribe = false; - firstLoaded = false; - loading: boolean; + loading = true; locale: string; showUpdatedSubscriptionStatusSection$: Observable; - manageBillingFromProviderPortal = ManageBilling; - isManagedByConsolidatedBillingMSP = false; enableTimeThreshold: boolean; preSelectedProductTier: ProductTierType = ProductTierType.Free; + showSubscription = true; + showSelfHost = false; + protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon; protected readonly teamsStarter = ProductTierType.TeamsStarter; - private destroy$ = new Subject(); - protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( FeatureFlag.EnableConsolidatedBilling, ); @@ -71,7 +68,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.EnableTimeThreshold, ); - protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( + protected enableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( FeatureFlag.EnableUpgradePasswordManagerSub, ); @@ -79,9 +76,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.AC2476_DeprecateStripeSourcesAPI, ); + private destroy$ = new Subject(); + constructor( private apiService: ApiService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private logService: LogService, private organizationService: OrganizationService, @@ -89,15 +87,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private route: ActivatedRoute, private dialogService: DialogService, private configService: ConfigService, - private providerService: ProviderService, private toastService: ToastService, + private billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { if (this.route.snapshot.queryParamMap.get("upgrade")) { - // 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.changePlan(); + await this.changePlan(); const productTierTypeStr = this.route.snapshot.queryParamMap.get("productTierType"); if (productTierTypeStr != null) { const productTier = Number(productTierTypeStr); @@ -112,7 +108,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy concatMap(async (params) => { this.organizationId = params.organizationId; await this.load(); - this.firstLoaded = true; }), takeUntil(this.destroy$), ) @@ -130,21 +125,34 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } async load() { - if (this.loading) { - return; - } this.loading = true; this.locale = await firstValueFrom(this.i18nService.locale$); this.userOrg = await this.organizationService.get(this.organizationId); - if (this.userOrg.canViewSubscription) { - const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); - const provider = await this.providerService.get(this.userOrg.providerId); - this.isManagedByConsolidatedBillingMSP = - enableConsolidatedBilling && - this.userOrg.hasProvider && - provider?.providerStatus == ProviderStatusType.Billable; + /* + +--------------------+--------------+----------------------+--------------+ + | User Type | Has Provider | Consolidated Billing | Subscription | + +--------------------+--------------+----------------------+--------------+ + | Organization Owner | False | N/A | Shown | + | Organization Owner | True | N/A | Hidden | + | Provider User | True | False | Shown | + | Provider User | True | True | Hidden | + +--------------------+--------------+----------------------+--------------+ + */ + const consolidatedBillingEnabled = await firstValueFrom(this.enableConsolidatedBilling$); + + this.showSubscription = + (!this.userOrg.hasProvider && this.userOrg.isOwner) || + (this.userOrg.hasProvider && this.userOrg.isProviderUser && !consolidatedBillingEnabled); + + const metadata = await this.billingApiService.getOrganizationBillingMetadata( + this.organizationId, + ); + + this.showSelfHost = metadata.isEligibleForSelfHost; + + if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.lineItems = this.sub?.subscription?.items; @@ -277,26 +285,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.sub.subscription?.items.some((i) => i.sponsoredSubscriptionItem); } - get canDownloadLicense() { - return ( - (this.sub.planType !== PlanType.Free && this.subscription == null) || - (this.subscription != null && !this.subscription.cancelled) - ); - } - - get canManageBillingSync() { - return ( - this.sub.planType === PlanType.EnterpriseAnnually || - this.sub.planType === PlanType.EnterpriseMonthly || - this.sub.planType === PlanType.EnterpriseAnnually2023 || - this.sub.planType === PlanType.EnterpriseMonthly2023 || - this.sub.planType === PlanType.EnterpriseAnnually2020 || - this.sub.planType === PlanType.EnterpriseMonthly2020 || - this.sub.planType === PlanType.EnterpriseAnnually2019 || - this.sub.planType === PlanType.EnterpriseMonthly2019 - ); - } - get subscriptionDesc() { if (this.sub.planType === PlanType.Free) { return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); @@ -353,13 +341,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy ); } - shownSelfHost(): boolean { - return ( - this.sub?.plan.productTier !== ProductTierType.Teams && - this.sub?.plan.productTier !== ProductTierType.Free - ); - } - cancelSubscription = async () => { const reference = openOffboardingSurvey(this.dialogService, { data: { @@ -399,9 +380,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy title: null, message: this.i18nService.t("reinstated"), }); - // 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.load(); + await this.load(); } catch (e) { this.logService.error(e); } @@ -409,7 +388,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy async changePlan() { const EnableUpgradePasswordManagerSub = await firstValueFrom( - this.EnableUpgradePasswordManagerSub$, + this.enableUpgradePasswordManagerSub$, ); if (EnableUpgradePasswordManagerSub) { const reference = openChangePlanDialog(this.dialogService, { @@ -458,24 +437,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy }); await firstValueFrom(dialogRef.closed); - // 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.load(); + await this.load(); } - closeDownloadLicense() { - this.showDownloadLicense = false; - } - - subscriptionAdjusted() { - // 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.load(); + async subscriptionAdjusted() { + await this.load(); } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total / (1 - this.customerDiscount?.percentOff / 100); - return discountedTotal; + return total / (1 - this.customerDiscount?.percentOff / 100); } adjustStorage = (add: boolean) => { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d9be5d769bd..c50775efa6e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9457,5 +9457,15 @@ }, "permanentlyDeleteAttachmentConfirmation": { "message": "Are you sure you want to permanently delete this attachment?" + }, + "manageSubscriptionFromThe": { + "message": "Manage subscription from the", + "description": "This represents the beginning of a sentence. The full sentence will be 'Manage subscription from the Provider Portal', but 'Provider Portal' will be a link and thus cannot be included in the translation file." + }, + "toHostBitwardenOnYourOwnServer": { + "message": "To host Bitwarden on your own server, you will need to upload your license file. To support Free Families plans and advanced billing capabilities for your self-hosted organization, you will need to set up automatic sync in your self-hosted organization." + }, + "selfHostingTitleProper": { + "message": "Self-Hosting" } } diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 33d7907fa88..4831d290698 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,10 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { + isEligibleForSelfHost: boolean; isOnSecretsManagerStandalone: boolean; constructor(response: any) { super(response); + this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); } } From 4b9fbfc8326d7f1c714a7f4a0738484df06f2bad Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:45:07 -0700 Subject: [PATCH 04/41] [PM-13769] - fix routing for send created page (#11629) * fix routing for send created page * fix test --------- Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .../popup/send-v2/add-edit/send-add-edit.component.ts | 4 ++-- .../send-v2/send-created/send-created.component.html | 4 ++-- .../send-created/send-created.component.spec.ts | 10 +++++++--- .../send-v2/send-created/send-created.component.ts | 8 +++++++- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 407a4d414a5..585f6067e3d 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -114,8 +114,8 @@ export class SendAddEditComponent { /** * Handles the event when the send is updated. */ - onSendUpdated(send: SendView) { - this.location.back(); + async onSendUpdated(_: SendView) { + await this.router.navigate(["/tabs/send"]); } deleteSend = async () => { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 7c65cbeb17d..cdb514b8047 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -4,7 +4,7 @@ slot="header" [pageTitle]="'createdSend' | i18n" showBackButton - [backAction]="close.bind(this)" + [backAction]="goToEditSend.bind(this)" > @@ -27,7 +27,7 @@ - diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 24186ad4275..fdf147b360f 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,6 +11,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; @@ -50,6 +51,7 @@ describe("SendCreatedComponent", () => { sendView = { id: sendId, deletionDate: new Date(), + type: SendType.Text, accessId: "abc", urlB64Key: "123", } as SendView; @@ -129,9 +131,11 @@ describe("SendCreatedComponent", () => { expect(component["hoursAvailable"]).toBe(0); }); - it("should navigate back to send list on close", async () => { - await component.close(); - expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]); + it("should navigate back to the edit send form on close", async () => { + await component.goToEditSend(); + expect(router.navigate).toHaveBeenCalledWith(["/edit-send"], { + queryParams: { sendId: "test-send-id", type: SendType.Text }, + }); }); describe("getHoursAvailable", () => { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 88475d7dad9..98b09d380e4 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -77,7 +77,13 @@ export class SendCreatedComponent { return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60))); } - async close() { + async goToEditSend() { + await this.router.navigate([`/edit-send`], { + queryParams: { sendId: this.send.id, type: this.send.type }, + }); + } + + async goBack() { await this.router.navigate(["/tabs/send"]); } From 116d2166c32926cd9fbd5785abb4f772243ae522 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:05:15 -0500 Subject: [PATCH 05/41] remove slideIn animation as it doesn't support the "show animations" setting (#11591) --- .../vault-generator-dialog.component.html | 2 +- .../vault-generator-dialog.component.ts | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html index 7652b8ab0bf..72aaeea493d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html @@ -1,4 +1,4 @@ - + Date: Mon, 21 Oct 2024 11:50:50 -0700 Subject: [PATCH 06/41] fix voiceover on send created screen (#11628) --- .../popup/send-v2/send-created/send-created.component.html | 4 +++- .../components/send-details/send-details.component.html | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index cdb514b8047..af3abbf5427 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -15,7 +15,9 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" > -

{{ "createdSendSuccessfully" | i18n }}

+

+ {{ "createdSendSuccessfully" | i18n }} +

{{ formatExpirationDate() }}

diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index d4c253303bd..93db4df3187 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + Date: Mon, 21 Oct 2024 13:36:27 -0700 Subject: [PATCH 07/41] [PM-13809] - add remove password button (#11641) * add remove password button * adjust comment * use bitAction directive --- .../options/send-options.component.html | 59 +++++++++++-------- .../options/send-options.component.ts | 49 ++++++++++++++- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index 265016ad1b1..98da24b5188 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -12,33 +12,44 @@ > - {{ "password" | i18n }} - {{ "newPassword" | i18n }} + {{ "password" | i18n }} + + + + + - - {{ "sendPasswordDescV3" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index c8122dd4315..48ab78465c1 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -7,14 +7,20 @@ import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + AsyncActionsModule, + ButtonModule, CardComponent, CheckboxModule, + DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, + ToastService, TypographyModule, } from "@bitwarden/components"; import { CredentialGeneratorService, Generators } from "@bitwarden/generator-core"; @@ -27,6 +33,8 @@ import { SendFormContainer } from "../../send-form-container"; templateUrl: "./send-options.component.html", standalone: true, imports: [ + AsyncActionsModule, + ButtonModule, CardComponent, CheckboxModule, CommonModule, @@ -53,7 +61,7 @@ export class SendOptionsComponent implements OnInit { hideEmail: [false as boolean], }); - get shouldShowNewPassword(): boolean { + get hasPassword(): boolean { return this.originalSendView && this.originalSendView.password !== null; } @@ -71,8 +79,12 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, + private dialogService: DialogService, + private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, + private i18nService: I18nService, + private toastService: ToastService, private generatorService: CredentialGeneratorService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -110,16 +122,49 @@ export class SendOptionsComponent implements OnInit { }); }; + removePassword = async () => { + if (!this.originalSendView || !this.originalSendView.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendOptionsForm.patchValue({ + password: null, + }); + this.sendOptionsForm.get("password")?.enable(); + }; + ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: null, + password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } + if (this.hasPassword) { + this.sendOptionsForm.get("password")?.disable(); + } + if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); } From 79cdf3bf5031264340d4c263547d1092cbb8c2d1 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 20:53:30 +0000 Subject: [PATCH 08/41] Bumped client version(s) (#11648) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index deb13794a54..00e09536616 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.10.2", + "version": "2024.10.3", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/package-lock.json b/package-lock.json index c679767699a..184ab0c92de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -248,7 +248,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2024.10.2" + "version": "2024.10.3" }, "libs/admin-console": { "name": "@bitwarden/admin-console", From c16d1e0e74ef46c8020008707f9d29f812ac3c74 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:52:07 -0400 Subject: [PATCH 09/41] AnonLayoutWrapperComponents - Add reset support for null values (#11651) --- ...extension-anon-layout-wrapper.component.ts | 24 +++++++++++-------- .../anon-layout-wrapper.component.ts | 24 +++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 9d7644878d0..db85b28fa64 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -131,31 +131,35 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { return; } - if (data.pageTitle) { - this.pageTitle = this.handleStringOrTranslation(data.pageTitle); + // Null emissions are used to reset the page data as all fields are optional. + + if (data.pageTitle !== undefined) { + this.pageTitle = + data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null; } - if (data.pageSubtitle) { - this.pageSubtitle = this.handleStringOrTranslation(data.pageSubtitle); + if (data.pageSubtitle !== undefined) { + this.pageSubtitle = + data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null; } - if (data.pageIcon) { - this.pageIcon = data.pageIcon; + if (data.pageIcon !== undefined) { + this.pageIcon = data.pageIcon !== null ? data.pageIcon : null; } - if (data.showReadonlyHostname != null) { + if (data.showReadonlyHostname !== undefined) { this.showReadonlyHostname = data.showReadonlyHostname; } - if (data.showAcctSwitcher != null) { + if (data.showAcctSwitcher !== undefined) { this.showAcctSwitcher = data.showAcctSwitcher; } - if (data.showBackButton != null) { + if (data.showBackButton !== undefined) { this.showBackButton = data.showBackButton; } - if (data.showLogo != null) { + if (data.showLogo !== undefined) { this.showLogo = data.showLogo; } } diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 27446335740..f805da0700a 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -14,17 +14,17 @@ export interface AnonLayoutWrapperData { * If a string is provided, it will be presented as is (ex: Organization name) * If a Translation object (supports placeholders) is provided, it will be translated */ - pageTitle?: string | Translation; + pageTitle?: string | Translation | null; /** * The optional subtitle of the page. * If a string is provided, it will be presented as is (ex: user's email) * If a Translation object (supports placeholders) is provided, it will be translated */ - pageSubtitle?: string | Translation; + pageSubtitle?: string | Translation | null; /** * The optional icon to display on the page. */ - pageIcon?: Icon; + pageIcon?: Icon | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -114,19 +114,23 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { return; } - if (data.pageTitle) { - this.pageTitle = this.handleStringOrTranslation(data.pageTitle); + // Null emissions are used to reset the page data as all fields are optional. + + if (data.pageTitle !== undefined) { + this.pageTitle = + data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null; } - if (data.pageSubtitle) { - this.pageSubtitle = this.handleStringOrTranslation(data.pageSubtitle); + if (data.pageSubtitle !== undefined) { + this.pageSubtitle = + data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null; } - if (data.pageIcon) { - this.pageIcon = data.pageIcon; + if (data.pageIcon !== undefined) { + this.pageIcon = data.pageIcon !== null ? data.pageIcon : null; } - if (data.showReadonlyHostname != null) { + if (data.showReadonlyHostname !== undefined) { this.showReadonlyHostname = data.showReadonlyHostname; } From 9a1879b96c4943563fbe5627202dd149b23be1aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:07:45 +0200 Subject: [PATCH 10/41] [deps] Tools: Update @types/papaparse to v5.3.15 (#11645) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 184ab0c92de..05512349b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,7 +114,7 @@ "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/node-ipc": "9.2.3", - "@types/papaparse": "5.3.14", + "@types/papaparse": "5.3.15", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", @@ -9698,9 +9698,9 @@ } }, "node_modules/@types/papaparse": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", - "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 38440adf92f..372da701a86 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/node-ipc": "9.2.3", - "@types/papaparse": "5.3.14", + "@types/papaparse": "5.3.15", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", From 470ddf79ab1b2b4be402ebad8bf4fd560dc66fae Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 22 Oct 2024 08:46:45 -0400 Subject: [PATCH 11/41] [PM-12425] Remove FF: AC-2828_provider-portal-members-page (#11241) * Remove FF: AC-2828_provider-portal-members-page * Thomas' feedback: Fix provider layout --- ...tml => bulk-confirm-dialog.component.html} | 0 .../bulk/bulk-confirm-dialog.component.ts | 87 ++++++ .../components/bulk/bulk-confirm.component.ts | 132 --------- ...html => bulk-remove-dialog.component.html} | 0 .../bulk/bulk-remove-dialog.component.ts | 54 ++++ .../components/bulk/bulk-remove.component.ts | 76 ----- .../members/members.component.ts | 8 +- .../organizations/members/members.module.ts | 8 +- .../manage/bulk/bulk-confirm.component.ts | 37 --- .../manage/bulk/bulk-remove.component.ts | 24 -- .../dialogs/bulk-confirm-dialog.component.ts | 2 +- .../dialogs/bulk-remove-dialog.component.ts | 2 +- .../providers/manage/people.component.html | 203 ------------- .../providers/manage/people.component.ts | 267 ------------------ .../providers/providers-layout.component.html | 2 +- .../providers/providers-routing.module.ts | 27 +- .../providers/providers.module.ts | 6 - libs/common/src/enums/feature-flag.enum.ts | 4 +- 18 files changed, 164 insertions(+), 775 deletions(-) rename apps/web/src/app/admin-console/organizations/members/components/bulk/{bulk-confirm.component.html => bulk-confirm-dialog.component.html} (100%) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts delete mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts rename apps/web/src/app/admin-console/organizations/members/components/bulk/{bulk-remove.component.html => bulk-remove-dialog.component.html} (100%) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts delete mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html similarity index 100% rename from apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html rename to apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts new file mode 100644 index 00000000000..8e6ec1dbc34 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -0,0 +1,87 @@ +import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { firstValueFrom, map, Observable, switchMap } from "rxjs"; + +import { + OrganizationUserApiService, + OrganizationUserBulkConfirmRequest, + OrganizationUserBulkPublicKeyResponse, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response"; +import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { DialogService } from "@bitwarden/components"; + +import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; +import { BulkUserDetails } from "./bulk-status.component"; + +type BulkConfirmDialogParams = { + organizationId: string; + users: BulkUserDetails[]; +}; + +@Component({ + templateUrl: "bulk-confirm-dialog.component.html", +}) +export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { + organizationId: string; + organizationKey$: Observable; + users: BulkUserDetails[]; + + constructor( + protected cryptoService: CryptoService, + @Inject(DIALOG_DATA) protected dialogParams: BulkConfirmDialogParams, + protected encryptService: EncryptService, + private organizationUserApiService: OrganizationUserApiService, + protected i18nService: I18nService, + private stateProvider: StateProvider, + ) { + super(cryptoService, encryptService, i18nService); + + this.organizationId = dialogParams.organizationId; + this.organizationKey$ = this.stateProvider.activeUserId$.pipe( + switchMap((userId) => this.cryptoService.orgKeys$(userId)), + map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]), + takeUntilDestroyed(), + ); + this.users = dialogParams.users; + } + + protected getCryptoKey = async (): Promise => + await firstValueFrom(this.organizationKey$); + + protected getPublicKeys = async (): Promise< + ListResponse + > => + await this.organizationUserApiService.postOrganizationUsersPublicKey( + this.organizationId, + this.filteredUsers.map((user) => user.id), + ); + + protected isAccepted = (user: BulkUserDetails) => + user.status === OrganizationUserStatusType.Accepted; + + protected postConfirmRequest = async ( + userIdsWithKeys: { id: string; key: string }[], + ): Promise> => { + const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys); + return await this.organizationUserApiService.postOrganizationUserBulkConfirm( + this.organizationId, + request, + ); + }; + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkConfirmDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts deleted file mode 100644 index ee506840628..00000000000 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; -import { Component, Inject, OnInit } from "@angular/core"; - -import { - OrganizationUserApiService, - OrganizationUserBulkConfirmRequest, -} from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { DialogService } from "@bitwarden/components"; - -import { BulkUserDetails } from "./bulk-status.component"; - -type BulkConfirmDialogData = { - organizationId: string; - users: BulkUserDetails[]; -}; - -@Component({ - selector: "app-bulk-confirm", - templateUrl: "bulk-confirm.component.html", -}) -export class BulkConfirmComponent implements OnInit { - organizationId: string; - users: BulkUserDetails[]; - - excludedUsers: BulkUserDetails[]; - filteredUsers: BulkUserDetails[]; - publicKeys: Map = new Map(); - fingerprints: Map = new Map(); - statuses: Map = new Map(); - - loading = true; - done = false; - error: string; - - constructor( - @Inject(DIALOG_DATA) protected data: BulkConfirmDialogData, - protected cryptoService: CryptoService, - protected encryptService: EncryptService, - protected apiService: ApiService, - private organizationUserApiService: OrganizationUserApiService, - private i18nService: I18nService, - ) { - this.organizationId = data.organizationId; - this.users = data.users; - } - - async ngOnInit() { - this.excludedUsers = this.users.filter((u) => !this.isAccepted(u)); - this.filteredUsers = this.users.filter((u) => this.isAccepted(u)); - - if (this.filteredUsers.length <= 0) { - this.done = true; - } - - const response = await this.getPublicKeys(); - - for (const entry of response.data) { - const publicKey = Utils.fromB64ToArray(entry.key); - const fingerprint = await this.cryptoService.getFingerprint(entry.userId, publicKey); - if (fingerprint != null) { - this.publicKeys.set(entry.id, publicKey); - this.fingerprints.set(entry.id, fingerprint.join("-")); - } - } - - this.loading = false; - } - - async submit() { - this.loading = true; - try { - const key = await this.getCryptoKey(); - const userIdsWithKeys: any[] = []; - for (const user of this.filteredUsers) { - const publicKey = this.publicKeys.get(user.id); - if (publicKey == null) { - continue; - } - const encryptedKey = await this.encryptService.rsaEncrypt(key.key, publicKey); - userIdsWithKeys.push({ - id: user.id, - key: encryptedKey.encryptedString, - }); - } - const response = await this.postConfirmRequest(userIdsWithKeys); - - response.data.forEach((entry) => { - const error = entry.error !== "" ? entry.error : this.i18nService.t("bulkConfirmMessage"); - this.statuses.set(entry.id, error); - }); - - this.done = true; - } catch (e) { - this.error = e.message; - } - this.loading = false; - } - - protected isAccepted(user: BulkUserDetails) { - return user.status === OrganizationUserStatusType.Accepted; - } - - protected async getPublicKeys() { - return await this.organizationUserApiService.postOrganizationUsersPublicKey( - this.organizationId, - this.filteredUsers.map((user) => user.id), - ); - } - - protected getCryptoKey(): Promise { - return this.cryptoService.getOrgKey(this.organizationId); - } - - protected async postConfirmRequest(userIdsWithKeys: any[]) { - const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys); - return await this.organizationUserApiService.postOrganizationUserBulkConfirm( - this.organizationId, - request, - ); - } - - static open(dialogService: DialogService, config: DialogConfig) { - return dialogService.open(BulkConfirmComponent, config); - } -} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html similarity index 100% rename from apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html rename to apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts new file mode 100644 index 00000000000..9ff097debb0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts @@ -0,0 +1,54 @@ +import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { + OrganizationUserApiService, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService } from "@bitwarden/components"; + +import { BaseBulkRemoveComponent } from "./base-bulk-remove.component"; +import { BulkUserDetails } from "./bulk-status.component"; + +type BulkRemoveDialogParams = { + organizationId: string; + users: BulkUserDetails[]; +}; + +@Component({ + templateUrl: "bulk-remove-dialog.component.html", +}) +export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { + organizationId: string; + users: BulkUserDetails[]; + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: BulkRemoveDialogParams, + protected i18nService: I18nService, + private organizationUserApiService: OrganizationUserApiService, + ) { + super(i18nService); + this.organizationId = dialogParams.organizationId; + this.users = dialogParams.users; + this.showNoMasterPasswordWarning = this.users.some( + (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, + ); + } + + protected deleteUsers = (): Promise> => + this.organizationUserApiService.removeManyOrganizationUsers( + this.organizationId, + this.users.map((user) => user.id), + ); + + protected get removeUsersWarning() { + return this.i18nService.t("removeOrgUsersConfirmation"); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkRemoveDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts deleted file mode 100644 index 74939238fcc..00000000000 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; -import { Component, Inject } from "@angular/core"; - -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService } from "@bitwarden/components"; - -import { BulkUserDetails } from "./bulk-status.component"; - -type BulkRemoveDialogData = { - organizationId: string; - users: BulkUserDetails[]; -}; - -@Component({ - selector: "app-bulk-remove", - templateUrl: "bulk-remove.component.html", -}) -export class BulkRemoveComponent { - organizationId: string; - users: BulkUserDetails[]; - - statuses: Map = new Map(); - - loading = false; - done = false; - error: string; - showNoMasterPasswordWarning = false; - - constructor( - @Inject(DIALOG_DATA) protected data: BulkRemoveDialogData, - protected apiService: ApiService, - protected i18nService: I18nService, - private organizationUserApiService: OrganizationUserApiService, - ) { - this.organizationId = data.organizationId; - this.users = data.users; - this.showNoMasterPasswordWarning = this.users.some( - (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, - ); - } - - submit = async () => { - this.loading = true; - try { - const response = await this.removeUsers(); - - response.data.forEach((entry) => { - const error = entry.error !== "" ? entry.error : this.i18nService.t("bulkRemovedMessage"); - this.statuses.set(entry.id, error); - }); - this.done = true; - } catch (e) { - this.error = e.message; - } - - this.loading = false; - }; - - protected async removeUsers() { - return await this.organizationUserApiService.removeManyOrganizationUsers( - this.organizationId, - this.users.map((user) => user.id), - ); - } - - protected get removeUsersWarning() { - return this.i18nService.t("removeOrgUsersConfirmation"); - } - - static open(dialogService: DialogService, config: DialogConfig) { - return dialogService.open(BulkRemoveComponent, config); - } -} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 3cc73c84a97..7ee99ff2e34 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -60,9 +60,9 @@ import { GroupService } from "../core"; import { OrganizationUserView } from "../core/views/organization-user.view"; import { openEntityEventsDialog } from "../manage/entity-events.component"; -import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.component"; +import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; -import { BulkRemoveComponent } from "./components/bulk/bulk-remove.component"; +import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { @@ -541,7 +541,7 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkRemoveComponent.open(this.dialogService, { + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { data: { organizationId: this.organization.id, users: this.dataSource.getCheckedUsers(), @@ -620,7 +620,7 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkConfirmComponent.open(this.dialogService, { + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { data: { organizationId: this.organization.id, users: this.dataSource.getCheckedUsers(), diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index d849b1f1f3c..d7c5a9bf1df 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -7,9 +7,9 @@ import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; -import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.component"; +import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; -import { BulkRemoveComponent } from "./components/bulk/bulk-remove.component"; +import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; @@ -28,9 +28,9 @@ import { MembersComponent } from "./members.component"; PasswordStrengthV2Component, ], declarations: [ - BulkConfirmComponent, + BulkConfirmDialogComponent, BulkEnableSecretsManagerDialogComponent, - BulkRemoveComponent, + BulkRemoveDialogComponent, BulkRestoreRevokeComponent, BulkStatusComponent, MembersComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts deleted file mode 100644 index 918673e63f5..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Component, Input } from "@angular/core"; - -import { ProviderUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ProviderUserBulkConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk-confirm.request"; -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { BulkConfirmComponent as OrganizationBulkConfirmComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-confirm.component"; -import { BulkUserDetails } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; - -/** - * @deprecated Please use the {@link BulkConfirmDialogComponent} instead. - */ -@Component({ - templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html", -}) -export class BulkConfirmComponent extends OrganizationBulkConfirmComponent { - @Input() providerId: string; - - protected override isAccepted(user: BulkUserDetails) { - return user.status === ProviderUserStatusType.Accepted; - } - - protected override async getPublicKeys() { - const request = new ProviderUserBulkRequest(this.filteredUsers.map((user) => user.id)); - return await this.apiService.postProviderUsersPublicKey(this.providerId, request); - } - - protected override getCryptoKey(): Promise { - return this.cryptoService.getProviderKey(this.providerId); - } - - protected override async postConfirmRequest(userIdsWithKeys: any[]) { - const request = new ProviderUserBulkConfirmRequest(userIdsWithKeys); - return await this.apiService.postProviderUserBulkConfirm(this.providerId, request); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts deleted file mode 100644 index ea3ea9b5967..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, Input } from "@angular/core"; - -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { BulkRemoveComponent as OrganizationBulkRemoveComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-remove.component"; - -/** - * @deprecated Please use the {@link BulkRemoveDialogComponent} instead. - */ -@Component({ - templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html", -}) -export class BulkRemoveComponent extends OrganizationBulkRemoveComponent { - @Input() providerId: string; - - async deleteUsers() { - const request = new ProviderUserBulkRequest(this.users.map((user) => user.id)); - return await this.apiService.deleteManyProviderUsers(this.providerId, request); - } - - protected get removeUsersWarning() { - return this.i18nService.t("removeUsersWarning"); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 8a04cb6452d..61145efb783 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -27,7 +27,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html", + "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { providerId: string; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 16e64703700..b5d5274498c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -17,7 +17,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html", + "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { providerId: string; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html deleted file mode 100644 index 36bc6543696..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html +++ /dev/null @@ -1,203 +0,0 @@ - - - - - -
- - - {{ "all" | i18n }} - {{ allCount }} - - - - {{ "invited" | i18n }} - {{ invitedCount }} - - - - {{ "needsConfirmation" | i18n }} - {{ acceptedCount }} - - - - -
- - - - {{ "loading" | i18n }} - - -

{{ "noUsersInList" | i18n }}

- - - {{ "providerUsersNeedConfirmed" | i18n }} - - - - - - - - - - - - -
- - - - - {{ u.email }} - {{ - "invited" | i18n - }} - {{ - "needsConfirmation" | i18n - }} - {{ u.name }} - - - - {{ "userUsingTwoStep" | i18n }} - - - {{ "providerAdmin" | i18n }} - {{ "serviceUser" | i18n }} - - -
-
-
- - - - - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts deleted file mode 100644 index 9293f8c6eb7..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { lastValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; - -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; -import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; -import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { BasePeopleComponent } from "@bitwarden/web-vault/app/admin-console/common/base.people.component"; -import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; -import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; - -import { BulkConfirmComponent } from "./bulk/bulk-confirm.component"; -import { BulkRemoveComponent } from "./bulk/bulk-remove.component"; -import { UserAddEditComponent } from "./user-add-edit.component"; - -/** - * @deprecated Please use the {@link MembersComponent} instead. - */ -@Component({ - selector: "provider-people", - templateUrl: "people.component.html", -}) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PeopleComponent - extends BasePeopleComponent - implements OnInit -{ - @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; - @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) - groupsModalRef: ViewContainerRef; - @ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true }) - bulkStatusModalRef: ViewContainerRef; - @ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true }) - bulkConfirmModalRef: ViewContainerRef; - @ViewChild("bulkRemoveTemplate", { read: ViewContainerRef, static: true }) - bulkRemoveModalRef: ViewContainerRef; - - userType = ProviderUserType; - userStatusType = ProviderUserStatusType; - status: ProviderUserStatusType = null; - providerId: string; - accessEvents = false; - - constructor( - apiService: ApiService, - private route: ActivatedRoute, - i18nService: I18nService, - modalService: ModalService, - platformUtilsService: PlatformUtilsService, - cryptoService: CryptoService, - private encryptService: EncryptService, - private router: Router, - searchService: SearchService, - validationService: ValidationService, - logService: LogService, - searchPipe: SearchPipe, - userNamePipe: UserNamePipe, - private providerService: ProviderService, - dialogService: DialogService, - organizationManagementPreferencesService: OrganizationManagementPreferencesService, - private configService: ConfigService, - protected toastService: ToastService, - ) { - super( - apiService, - searchService, - i18nService, - platformUtilsService, - cryptoService, - validationService, - modalService, - logService, - searchPipe, - userNamePipe, - dialogService, - organizationManagementPreferencesService, - toastService, - ); - } - - ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; - const provider = await this.providerService.get(this.providerId); - - if (!provider.canManageUsers) { - // 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(["../"], { relativeTo: this.route }); - return; - } - - this.accessEvents = provider.useEvents; - - await this.load(); - - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchControl.setValue(qParams.search); - if (qParams.viewEvents != null) { - const user = this.users.filter((u) => u.id === qParams.viewEvents); - if (user.length > 0 && user[0].status === ProviderUserStatusType.Confirmed) { - // 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.events(user[0]); - } - } - }); - }); - } - - getUsers(): Promise> { - return this.apiService.getProviderUsers(this.providerId); - } - - deleteUser(id: string): Promise { - return this.apiService.deleteProviderUser(this.providerId, id); - } - - revokeUser(id: string): Promise { - // Not implemented. - return null; - } - - restoreUser(id: string): Promise { - // Not implemented. - return null; - } - - reinviteUser(id: string): Promise { - return this.apiService.postProviderUserReinvite(this.providerId, id); - } - - async confirmUser(user: ProviderUserUserDetailsResponse, publicKey: Uint8Array): Promise { - const providerKey = await this.cryptoService.getProviderKey(this.providerId); - const key = await this.encryptService.rsaEncrypt(providerKey.key, publicKey); - const request = new ProviderUserConfirmRequest(); - request.key = key.encryptedString; - await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); - } - - async edit(user: ProviderUserUserDetailsResponse) { - const [modal] = await this.modalService.openViewRef( - UserAddEditComponent, - this.addEditModalRef, - (comp) => { - comp.name = this.userNamePipe.transform(user); - comp.providerId = this.providerId; - comp.providerUserId = user != null ? user.id : null; - comp.savedUser.subscribe(() => { - modal.close(); - this.load(); - }); - comp.deletedUser.subscribe(() => { - modal.close(); - this.removeUser(user); - }); - }, - ); - } - - async events(user: ProviderUserUserDetailsResponse) { - await openEntityEventsDialog(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - providerId: this.providerId, - entityId: user.id, - showUser: false, - entity: "user", - }, - }); - } - - async bulkRemove() { - if (this.actionPromise != null) { - return; - } - - const [modal] = await this.modalService.openViewRef( - BulkRemoveComponent, - this.bulkRemoveModalRef, - (comp) => { - comp.providerId = this.providerId; - comp.users = this.getCheckedUsers(); - }, - ); - - await modal.onClosedPromise(); - await this.load(); - } - - async bulkReinvite() { - if (this.actionPromise != null) { - return; - } - - const users = this.getCheckedUsers(); - const filteredUsers = users.filter((u) => u.status === ProviderUserStatusType.Invited); - - if (filteredUsers.length <= 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noSelectedUsersApplicable"), - }); - return; - } - - try { - const request = new ProviderUserBulkRequest(filteredUsers.map((user) => user.id)); - const response = this.apiService.postManyProviderUserReinvite(this.providerId, request); - // 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 - - // Bulk Status component open - const dialogRef = BulkStatusComponent.open(this.dialogService, { - data: { - users: users, - filteredUsers: filteredUsers, - request: response, - successfulMessage: this.i18nService.t("bulkReinviteMessage"), - }, - }); - await lastValueFrom(dialogRef.closed); - } catch (e) { - this.validationService.showError(e); - } - this.actionPromise = null; - } - - async bulkConfirm() { - if (this.actionPromise != null) { - return; - } - - const [modal] = await this.modalService.openViewRef( - BulkConfirmComponent, - this.bulkConfirmModalRef, - (comp) => { - comp.providerId = this.providerId; - comp.users = this.getCheckedUsers(); - }, - ); - - await modal.onClosedPromise(); - await this.load(); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 5f9b3f66bc5..0536221cafd 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -13,7 +13,7 @@ route="manage" *ngIf="showManageTab(provider)" > - + provider.canManageUsers), - ], - data: { - titleId: "people", - }, + { + path: "members", + component: MembersComponent, + canActivate: [ + providerPermissionsGuard((provider: Provider) => provider.canManageUsers), + ], + data: { + titleId: "members", }, - }), + }, { path: "events", component: EventsComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 8ed10a2d6e3..b6c7125c48c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -24,14 +24,11 @@ import { AddOrganizationComponent } from "./clients/add-organization.component"; import { ClientsComponent } from "./clients/clients.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; import { AcceptProviderComponent } from "./manage/accept-provider.component"; -import { BulkConfirmComponent } from "./manage/bulk/bulk-confirm.component"; -import { BulkRemoveComponent } from "./manage/bulk/bulk-remove.component"; import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component"; import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog.component"; import { BulkRemoveDialogComponent } from "./manage/dialogs/bulk-remove-dialog.component"; import { EventsComponent } from "./manage/events.component"; import { MembersComponent } from "./manage/members.component"; -import { PeopleComponent } from "./manage/people.component"; import { UserAddEditComponent } from "./manage/user-add-edit.component"; import { ProvidersLayoutComponent } from "./providers-layout.component"; import { ProvidersRoutingModule } from "./providers-routing.module"; @@ -58,14 +55,11 @@ import { SetupComponent } from "./setup/setup.component"; AcceptProviderComponent, AccountComponent, AddOrganizationComponent, - BulkConfirmComponent, BulkConfirmDialogComponent, - BulkRemoveComponent, BulkRemoveDialogComponent, ClientsComponent, CreateOrganizationComponent, EventsComponent, - PeopleComponent, MembersComponent, SetupComponent, SetupProviderComponent, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index fd3833d10e3..905d7299489 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -20,7 +20,7 @@ export enum FeatureFlag { EnableTimeThreshold = "PM-5864-dollar-threshold", InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", - AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page", + VaultBulkManagementAction = "vault-bulk-management-action", IdpAutoSubmitLogin = "idp-auto-submit-login", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", @@ -67,7 +67,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableTimeThreshold]: FALSE, [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, - [FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE, + [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.IdpAutoSubmitLogin]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, From 4a30782939012dbed73d9e659cf6461696af0dce Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 22 Oct 2024 15:15:15 +0200 Subject: [PATCH 12/41] [PM-12281] [PM-12301] [PM-12306] [PM-12334] Move delete item permission to Can Manage (#11289) * Added inputs to the view and edit component to disable or remove the delete button when a user does not have manage rights * Refactored editByCipherId to receive cipherview object * Fixed issue where adding an item on the individual vault throws a null reference * Fixed issue where adding an item on the AC vault throws a null reference * Allow delete in unassigned collection * created reusable service to check if a user has delete permission on an item * Registered service * Used authorizationservice on the browser and desktop Only display the delete button when a user has delete permission * Added comments to the service * Passed active collectionId to add edit component renamed constructor parameter * restored input property used by the web * Fixed dependency issue * Fixed dependency issue * Fixed dependency issue * Modified service to cater for org vault * Updated to include new dependency * Updated components to use the observable * Added check on the cli to know if user has rights to delete an item * Renamed abstraction and renamed implementation to include Default Fixed permission issues * Fixed test to reflect changes in implementation * Modified base classes to use new naming Passed new parameters for the canDeleteCipher * Modified base classes to use new naming Made changes from base class * Desktop changes Updated reference naming * cli changes Updated reference naming Passed new parameters for the canDeleteCipher$ * Updated references * browser changes Updated reference naming Passed new parameters for the canDeleteCipher$ * Modified cipher form dialog to take in active collection id used canDeleteCipher$ on the vault item dialog to disable the delete button when user does not have the required permissions * Fix number of arguments issue * Added active collection id * Updated canDeleteCipher$ arguments * Updated to pass the cipher object * Fixed up refrences and comments * Updated dependency * updated check to canEditUnassignedCiphers * Fixed unit tests * Removed activeCollectionId from cipher form * Fixed issue where bulk delete option shows for can edit users * Fix null reference when checking if a cipher belongs to the unassigned collection * Fixed bug where allowedCollection passed is undefined * Modified cipher by adding a isAdminConsoleAction argument to tell when a reuqest comes from the admin console * Passed isAdminConsoleAction as true when request is from the admin console --- .../browser/src/background/main.background.ts | 10 + .../vault-v2/view-v2/view-v2.component.html | 2 +- .../view-v2/view-v2.component.spec.ts | 7 + .../vault-v2/view-v2/view-v2.component.ts | 5 + .../components/vault/add-edit.component.html | 2 +- .../components/vault/add-edit.component.ts | 4 + .../components/vault/vault-items.component.ts | 2 +- .../components/vault/view.component.html | 2 +- .../popup/components/vault/view.component.ts | 18 +- apps/cli/src/oss-serve-configurator.ts | 1 + .../service-container/service-container.ts | 10 + apps/cli/src/vault.program.ts | 1 + apps/cli/src/vault/delete.command.ts | 10 + .../vault/app/vault/add-edit.component.html | 2 +- .../src/vault/app/vault/add-edit.component.ts | 3 + .../src/vault/app/vault/vault.component.html | 2 + .../src/vault/app/vault/view.component.html | 2 +- .../src/vault/app/vault/view.component.ts | 3 + .../emergency-add-edit-cipher.component.ts | 3 + .../vault-item-dialog.component.html | 2 +- .../vault-item-dialog.component.ts | 24 ++- .../vault-cipher-row.component.html | 2 +- .../vault-items/vault-cipher-row.component.ts | 1 + .../vault-items/vault-items.component.html | 8 +- .../vault-items/vault-items.component.ts | 93 +++++--- .../individual-vault/add-edit.component.html | 2 +- .../individual-vault/add-edit.component.ts | 3 + .../vault/individual-vault/vault.component.ts | 23 +- .../individual-vault/view.component.html | 2 +- .../individual-vault/view.component.spec.ts | 7 + .../vault/individual-vault/view.component.ts | 15 ++ .../app/vault/org-vault/add-edit.component.ts | 4 + .../app/vault/org-vault/vault.component.html | 1 + .../app/vault/org-vault/vault.component.ts | 11 +- .../src/services/jslib-services.module.ts | 9 + .../vault/components/add-edit.component.ts | 17 +- .../src/vault/components/view.component.ts | 10 +- .../cipher-authorization.service.spec.ts | 200 ++++++++++++++++++ .../services/cipher-authorization.service.ts | 86 ++++++++ 39 files changed, 551 insertions(+), 58 deletions(-) create mode 100644 libs/common/src/vault/services/cipher-authorization.service.spec.ts create mode 100644 libs/common/src/vault/services/cipher-authorization.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a3dd1c473ae..e5a4087510c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -177,6 +177,10 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -369,6 +373,7 @@ export default class MainBackground { themeStateService: DefaultThemeStateService; autoSubmitLoginBackground: AutoSubmitLoginBackground; sdkService: SdkService; + cipherAuthorizationService: CipherAuthorizationService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -1265,6 +1270,11 @@ export default class MainBackground { } this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService); + + this.cipherAuthorizationService = new DefaultCipherAuthorizationService( + this.collectionService, + this.organizationService, + ); } async bootstrap() { diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index a778d6aaea9..c2645f15ea8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -28,7 +28,7 @@ -
+
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 38416c2c39c..ae2cf88fd1f 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -2,7 +2,7 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, Subject } from "rxjs"; +import { firstValueFrom, Observable, Subject } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -12,12 +12,13 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs 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 { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, ButtonModule, @@ -63,6 +64,16 @@ export interface VaultItemDialogParams { * If true, the "edit" button will be disabled in the dialog. */ disableForm?: boolean; + + /** + * The ID of the active collection. This is know the collection filter selected by the user. + */ + activeCollectionId?: CollectionId; + + /** + * If true, the dialog is being opened from the admin console. + */ + isAdminConsoleAction?: boolean; } export enum VaultItemDialogResult { @@ -204,6 +215,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected formConfig: CipherFormConfig = this.params.formConfig; + protected canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) protected params: VaultItemDialogParams, private dialogRef: DialogRef, @@ -217,6 +230,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private router: Router, private billingAccountProfileStateService: BillingAccountProfileStateService, private premiumUpgradeService: PremiumUpgradePromptService, + private cipherAuthorizationService: CipherAuthorizationService, ) { this.updateTitle(); } @@ -231,6 +245,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.organization = this.formConfig.organizations.find( (o) => o.id === this.cipher.organizationId, ); + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.params.activeCollectionId], + this.params.isAdminConsoleAction, + ); } this.performingInitialLoad = false; 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 2f38d7c70db..286bbbab5ef 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 @@ -132,7 +132,7 @@ {{ "restore" | i18n }} - - diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index 0dd58b846d7..d1bfd221175 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -14,6 +14,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component"; @@ -62,6 +63,12 @@ describe("ViewComponent", () => { useValue: mock(), }, { provide: ConfigService, useValue: mock() }, + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn().mockReturnValue(true), + }, + }, ], }).compileComponents(); 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 99829e8f086..d30c453a4bd 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,7 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -8,10 +9,12 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga 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 { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, DialogModule, @@ -34,6 +37,11 @@ export interface ViewCipherDialogParams { */ collections?: CollectionView[]; + /** + * Optional collection ID used to know the collection filter selected. + */ + activeCollectionId?: CollectionId; + /** * If true, the edit button will be disabled in the dialog. */ @@ -71,6 +79,8 @@ export class ViewComponent implements OnInit { cipherTypeString: string; organization: Organization; + canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) public params: ViewCipherDialogParams, private dialogRef: DialogRef, @@ -81,6 +91,7 @@ export class ViewComponent implements OnInit { private cipherService: CipherService, private toastService: ToastService, private organizationService: OrganizationService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} /** @@ -93,6 +104,10 @@ export class ViewComponent implements OnInit { if (this.cipher.organizationId) { this.organization = await this.organizationService.get(this.cipher.organizationId); } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.params.activeCollectionId, + ]); } /** 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 9cb5542a7b7..7a4697f5af6 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 @@ -21,6 +21,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -57,6 +58,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe: DatePipe, configService: ConfigService, billingAccountProfileStateService: BillingAccountProfileStateService, + cipherAuthorizationService: CipherAuthorizationService, ) { super( cipherService, @@ -79,6 +81,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe, configService, billingAccountProfileStateService, + cipherAuthorizationService, ); } @@ -90,6 +93,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected async loadCipher() { + this.isAdminConsoleAction = true; // Calling loadCipher first to assess if the cipher is unassigned. If null use apiService getCipherAdmin const firstCipherCheck = await super.loadCipher(); diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 220d6ef490f..0bcdc52eaeb 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -70,6 +70,7 @@ [viewingOrgVault]="true" [addAccessStatus]="addAccessStatus$ | async" [addAccessToggle]="showAddAccessToggle" + [activeCollection]="selectedCollection?.node" > 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 94bb6011dc7..060ff7824d2 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -828,6 +828,7 @@ export class VaultComponent implements OnInit, OnDestroy { comp.organization = this.organization; comp.organizationId = this.organization.id; comp.cipherId = cipher?.id; + comp.collectionId = this.activeFilter.collectionId; comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); this.refresh(); @@ -897,7 +898,12 @@ export class VaultComponent implements OnInit, OnDestroy { cipher.type, ); - await this.openVaultItemDialog("view", cipherFormConfig, cipher); + await this.openVaultItemDialog( + "view", + cipherFormConfig, + cipher, + this.activeFilter.collectionId as CollectionId, + ); } /** @@ -907,6 +913,7 @@ export class VaultComponent implements OnInit, OnDestroy { mode: VaultItemDialogMode, formConfig: CipherFormConfig, cipher?: CipherView, + activeCollectionId?: CollectionId, ) { const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; // If the form is disabled, force the mode into `view` @@ -915,6 +922,8 @@ export class VaultComponent implements OnInit, OnDestroy { mode: dialogMode, formConfig, disableForm, + activeCollectionId, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6af0fe2f660..e8d29bd69ba 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -242,6 +242,10 @@ import { } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -1340,6 +1344,11 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, ], }), + safeProvider({ + provide: CipherAuthorizationService, + useClass: DefaultCipherAuthorizationService, + deps: [CollectionService, OrganizationServiceAbstraction], + }), ]; @NgModule({ diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 49129a868be..44eaec03a68 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -23,7 +23,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CollectionId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; @@ -36,6 +36,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -47,6 +48,7 @@ export class AddEditComponent implements OnInit, OnDestroy { @Input() type: CipherType; @Input() collectionIds: string[]; @Input() organizationId: string = null; + @Input() collectionId: string = null; @Output() onSavedCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); @@ -57,6 +59,8 @@ export class AddEditComponent implements OnInit, OnDestroy { @Output() onGeneratePassword = new EventEmitter(); @Output() onGenerateUsername = new EventEmitter(); + canDeleteCipher$: Observable; + editMode = false; cipher: CipherView; folders$: Observable; @@ -83,6 +87,10 @@ export class AddEditComponent implements OnInit, OnDestroy { reprompt = false; canUseReprompt = true; organization: Organization; + /** + * Flag to determine if the action is being performed from the admin console. + */ + isAdminConsoleAction: boolean = false; protected componentName = ""; protected destroy$ = new Subject(); @@ -118,6 +126,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected win: Window, protected datePipe: DatePipe, protected configService: ConfigService, + protected cipherAuthorizationService: CipherAuthorizationService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -314,6 +323,12 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.reprompt) { this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.collectionId as CollectionId], + this.isAdminConsoleAction, + ); } async submit(): Promise { diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index ac644acf9e4..4c96c10dac3 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -9,7 +9,7 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -28,6 +28,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 { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -37,6 +38,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -45,12 +47,14 @@ const BroadcasterSubscriptionId = "ViewComponent"; @Directive() export class ViewComponent implements OnDestroy, OnInit { @Input() cipherId: string; + @Input() collectionId: string; @Output() onEditCipher = new EventEmitter(); @Output() onCloneCipher = new EventEmitter(); @Output() onShareCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); + canDeleteCipher$: Observable; cipher: CipherView; showPassword: boolean; showPasswordCount: boolean; @@ -105,6 +109,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected datePipe: DatePipe, protected accountService: AccountService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} ngOnInit() { @@ -144,6 +149,9 @@ export class ViewComponent implements OnDestroy, OnInit { ); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.collectionId as CollectionId, + ]); if (this.cipher.folderId) { this.folder = await ( diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts new file mode 100644 index 00000000000..3155825d4d0 --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts @@ -0,0 +1,200 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { CipherView } from "../models/view/cipher.view"; + +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "./cipher-authorization.service"; + +describe("CipherAuthorizationService", () => { + let cipherAuthorizationService: CipherAuthorizationService; + + const mockCollectionService = mock(); + const mockOrganizationService = mock(); + + // Mock factories + const createMockCipher = ( + organizationId: string | null, + collectionIds: string[], + edit: boolean = true, + ) => ({ + organizationId, + collectionIds, + edit, + }); + + const createMockCollection = (id: string, manage: boolean) => ({ + id, + manage, + }); + + const createMockOrganization = ({ + allowAdminAccessToAllCollectionItems = false, + canEditAllCiphers = false, + canEditUnassignedCiphers = false, + } = {}) => ({ + allowAdminAccessToAllCollectionItems, + canEditAllCiphers, + canEditUnassignedCiphers, + }); + + beforeEach(() => { + jest.clearAllMocks(); + cipherAuthorizationService = new DefaultCipherAuthorizationService( + mockCollectionService, + mockOrganizationService, + ); + }); + + describe("canDeleteCipher$", () => { + it("should return true if cipher has no organizationId", (done) => { + const cipher = createMockCipher(null, []) as CipherView; + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => { + const cipher = createMockCipher("org1", ["col1"]) as CipherView; + const organization = createMockOrganization({ canEditAllCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + expect(mockOrganizationService.get$).toHaveBeenCalledWith("org1"); + done(); + }); + }); + + it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: false }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return true if activeCollectionId is provided and has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", true), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if activeCollectionId is provided and manage permission is not present", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return true if any collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2", "col3"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + createMockCollection("col3", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + "col3", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if no collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts new file mode 100644 index 00000000000..00c7c412d61 --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -0,0 +1,86 @@ +import { map, Observable, of, switchMap } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { Cipher } from "../models/domain/cipher"; +import { CipherView } from "../models/view/cipher.view"; + +/** + * Represents either a cipher or a cipher view. + */ +type CipherLike = Cipher | CipherView; + +/** + * Service for managing user cipher authorization. + */ +export abstract class CipherAuthorizationService { + /** + * Determines if the user can delete the specified cipher. + * + * @param {CipherLike} cipher - The cipher object to evaluate for deletion permissions. + * @param {CollectionId[]} [allowedCollections] - Optional. The selected collection id from the vault filter. + * @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console. + * + * @returns {Observable} - An observable that emits a boolean value indicating if the user can delete the cipher. + */ + canDeleteCipher$: ( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ) => Observable; +} + +/** + * {@link CipherAuthorizationService} + */ +export class DefaultCipherAuthorizationService implements CipherAuthorizationService { + constructor( + private collectionService: CollectionService, + private organizationService: OrganizationService, + ) {} + + /** + * + * {@link CipherAuthorizationService.canDeleteCipher$} + */ + canDeleteCipher$( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ): Observable { + if (cipher.organizationId == null) { + return of(true); + } + + return this.organizationService.get$(cipher.organizationId).pipe( + switchMap((organization) => { + if (isAdminConsoleAction) { + // If the user is an admin, they can delete an unassigned cipher + if (!cipher.collectionIds || cipher.collectionIds.length === 0) { + return of(organization?.canEditUnassignedCiphers === true); + } + + if (organization?.canEditAllCiphers) { + return of(true); + } + } + + return this.collectionService + .decryptedCollectionViews$(cipher.collectionIds as CollectionId[]) + .pipe( + map((allCollections) => { + const shouldFilter = allowedCollections?.some(Boolean); + + const collections = shouldFilter + ? allCollections.filter((c) => allowedCollections.includes(c.id as CollectionId)) + : allCollections; + + return collections.some((collection) => collection.manage); + }), + ); + }), + ); + } +} From 023abe2969068a7fd229e70e3a81d76fc4476b4d Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 22 Oct 2024 10:07:22 -0400 Subject: [PATCH 13/41] [PM-11199] added permission labels to ciphers in AC (#11210) * added permission labels to ciphers in AC --- .../vault-cipher-row.component.html | 6 ++- .../vault-items/vault-cipher-row.component.ts | 52 ++++++++++++++++++- .../app/vault/org-vault/vault.component.ts | 1 + 3 files changed, 57 insertions(+), 2 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 286bbbab5ef..5c4de576ead 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 @@ -69,7 +69,11 @@ > - + +

+ {{ permissionText }} +

+
- + + >
{ + if (nav === "password") { + return this.i18nService.t("copyPassword"); + } + + if (nav === "passphrase") { + return this.i18nService.t("copyPassphrase"); + } + + return this.i18nService.t("copyUsername"); + }), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeGenerateLabel$ = this.root$.pipe( + map(({ nav }) => { + if (nav === "password") { + return this.i18nService.t("generatePassword"); + } + + if (nav === "passphrase") { + return this.i18nService.t("generatePassphrase"); + } + + return this.i18nService.t("generateUsername"); + }), + ); + protected onRootChanged(nav: RootNavValue) { // prevent subscription cycle if (this.root$.value.nav !== nav) { diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index b4cf8c6cdb6..aecdf0f6a4d 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -14,18 +14,21 @@
- + + >
(null); + /** + * Emits the copy button aria-label respective of the selected credential + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeCopyLabel$ = this.credentialType$.pipe( + map((cred) => { + if (cred === "password") { + return this.i18nService.t("copyPassword"); + } + + return this.i18nService.t("copyPassphrase"); + }), + ); + + /** + * Emits the generate button aria-label respective of the selected credential + * + * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. + */ + protected credentialTypeGenerateLabel$ = this.credentialType$.pipe( + map((cred) => { + if (cred === "password") { + return this.i18nService.t("generatePassword"); + } + + return this.i18nService.t("generatePassphrase"); + }), + ); + /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index e9d7d1c1f8c..ad8cd796123 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -3,18 +3,23 @@
- + + + + >
From 79d7d506df251e18dcbdc69e0c3aa59a85a0c28e Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:39:57 -0400 Subject: [PATCH 20/41] [PM-12996] Updating UI Spacing for bit section header (#11609) * Adding space to the section header * Updating spacing to the left of the bit section header --- .../vault-list-items-container.component.html | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index bea6d9631ca..e89ec9472fb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,18 +1,20 @@ - -

- {{ title }} -

- - {{ ciphers.length }} -
+
+ +

+ {{ title }} +

+ + {{ ciphers.length }} +
+
{{ description }}
From e67577cc39a82ca20a77625898de527397a3ee91 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:40:11 -0400 Subject: [PATCH 21/41] Updating chipSelect to be the new styling (#11593) --- .../vault-list-filters/vault-list-filters.component.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html index 0e241a81dcb..d9c4fbeee15 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html @@ -1,8 +1,12 @@
-
+ Date: Wed, 23 Oct 2024 12:11:42 -0400 Subject: [PATCH 22/41] [PM-8280] email forwarders (#11563) * forwarder lookup and generation support * localize algorithm names and descriptions in the credential generator service * add encryption support to UserStateSubject * move generic rx utilities to common * move icon button labels to generator configurations --- apps/browser/src/_locales/en/messages.json | 15 + apps/desktop/src/locales/en/messages.json | 18 + apps/web/src/locales/en/messages.json | 15 + libs/common/src/tools/dependencies.ts | 32 +- .../src/tools/integration/integration-id.ts | 14 +- libs/common/src/tools/private-classifier.ts | 31 + libs/common/src/tools/public-classifier.ts | 29 + libs/common/src/tools/rx.spec.ts | 496 +++++++++++++++- libs/common/src/tools/rx.ts | 125 +++- .../src/tools/state/classified-format.ts | 6 + .../tools/state/identity-state-constraint.ts | 26 +- libs/common/src/tools/state/object-key.ts | 53 ++ .../state/state-constraints-dependency.ts | 6 +- .../state/user-state-subject-dependencies.ts | 18 +- .../tools/state/user-state-subject.spec.ts | 315 +++++++--- .../src/tools/state/user-state-subject.ts | 416 +++++++++---- libs/common/src/tools/types.ts | 9 +- .../src/credential-generator.component.html | 27 +- .../src/credential-generator.component.ts | 376 ++++++++---- .../src/forwarder-settings.component.html | 16 + .../src/forwarder-settings.component.ts | 195 +++++++ .../components/src/generator.module.ts | 19 +- .../src/password-generator.component.ts | 54 +- .../src/username-generator.component.html | 23 +- .../src/username-generator.component.ts | 278 +++++++-- libs/tools/generator/components/src/util.ts | 2 +- .../core/src/data/generator-types.ts | 2 +- .../generator/core/src/data/generators.ts | 98 +++- .../generator/core/src/data/integrations.ts | 23 + .../src/engine/forwarder-configuration.ts | 35 +- .../generator/core/src/engine/forwarder.ts | 75 +++ .../generator/core/src/integration/addy-io.ts | 44 +- .../core/src/integration/duck-duck-go.ts | 42 +- .../core/src/integration/fastmail.ts | 46 +- .../core/src/integration/firefox-relay.ts | 42 +- .../core/src/integration/forward-email.ts | 41 +- .../core/src/integration/simple-login.ts | 42 +- libs/tools/generator/core/src/rx.spec.ts | 352 ----------- libs/tools/generator/core/src/rx.ts | 99 +--- .../credential-generator.service.spec.ts | 546 +++++++++++++++--- .../services/credential-generator.service.ts | 181 ++++-- .../credential-generator-configuration.ts | 78 ++- .../core/src/types/generator-type.ts | 31 +- libs/tools/generator/core/src/types/index.ts | 4 +- .../send-ui/src/send-form/send-form.module.ts | 13 +- 45 files changed, 3403 insertions(+), 1005 deletions(-) create mode 100644 libs/common/src/tools/private-classifier.ts create mode 100644 libs/common/src/tools/public-classifier.ts create mode 100644 libs/common/src/tools/state/object-key.ts create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.html create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.ts create mode 100644 libs/tools/generator/core/src/engine/forwarder.ts delete mode 100644 libs/tools/generator/core/src/rx.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7fb21952ddf..e72daaa1717 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1395,6 +1395,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -2833,6 +2837,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2873,6 +2880,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f119d7366d6..e04941bdb91 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -835,6 +835,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -1225,6 +1229,9 @@ "message": "Copy number", "description": "Copy credit card number" }, + "copyEmail": { + "message": "Copy email" + }, "copySecurityCode": { "message": "Copy security code", "description": "Copy credit card security code (CVV)" @@ -2359,6 +2366,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2402,6 +2412,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a27f13f9aee..07d94892adb 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6361,6 +6361,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -6466,6 +6469,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", @@ -8265,6 +8276,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "aliasDomain": { "message": "Alias domain" }, diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 8b860591d54..84e2f53fa29 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -3,6 +3,8 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "./state/user-encryptor.abstraction"; + /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { /** the userId pinned by the single user dependency */ @@ -45,7 +47,35 @@ export type UserDependency = { userId$: Observable; }; -/** A pattern for types that depend upon a fixed userid and return +/** Decorates a type to indicate the user, if any, that the type is usable only by + * a specific user. + */ +export type UserBound = { [P in K]: T } & { + /** The user to which T is bound. */ + userId: UserId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `UserChangedError` if + * the bound UserId changes or if the encryptor changes. If + * `singleUserEncryptor$` completes, the consumer should complete + * once all events received prior to the completion event are + * finished processing. The consumer should, where possible, + * prioritize these events in order to complete as soon as possible. + * If `singleUserEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleUserEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the user key + * is available, and completes when the user key is no longer available. + * The stream should not emit null or undefined. + */ + singleUserEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value userid and return * an observable. * * Consumers of this dependency should emit a `UserChangedError` if diff --git a/libs/common/src/tools/integration/integration-id.ts b/libs/common/src/tools/integration/integration-id.ts index 46b81c3c4c0..a15db143ee1 100644 --- a/libs/common/src/tools/integration/integration-id.ts +++ b/libs/common/src/tools/integration/integration-id.ts @@ -1,7 +1,13 @@ import { Opaque } from "type-fest"; +export const IntegrationIds = [ + "anonaddy", + "duckduckgo", + "fastmail", + "firefoxrelay", + "forwardemail", + "simplelogin", +] as const; + /** Identifies a vendor integrated into bitwarden */ -export type IntegrationId = Opaque< - "anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin", - "IntegrationId" ->; +export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">; diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts new file mode 100644 index 00000000000..f9648504b76 --- /dev/null +++ b/libs/common/src/tools/private-classifier.ts @@ -0,0 +1,31 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PrivateClassifier implements Classifier, Data> { + constructor(private keys: (keyof Jsonify)[] = undefined) {} + + classify(value: Data): { disclosed: Jsonify>; secret: Jsonify } { + const pickMe = JSON.parse(JSON.stringify(value)); + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(pickMe) as any); + + const picked: Partial> = {}; + for (const key of keys) { + picked[key] = pickMe[key]; + } + const secret = picked as Jsonify; + + return { disclosed: null, secret }; + } + + declassify(_disclosed: Jsonify>, secret: Jsonify) { + const result: Partial> = {}; + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(secret) as any); + + for (const key of keys) { + result[key] = secret[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts new file mode 100644 index 00000000000..82396f1c169 --- /dev/null +++ b/libs/common/src/tools/public-classifier.ts @@ -0,0 +1,29 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PublicClassifier implements Classifier> { + constructor(private keys: (keyof Jsonify)[]) {} + + classify(value: Data): { disclosed: Jsonify; secret: Jsonify> } { + const pickMe = JSON.parse(JSON.stringify(value)); + + const picked: Partial> = {}; + for (const key of this.keys) { + picked[key] = pickMe[key]; + } + const disclosed = picked as Jsonify; + + return { disclosed, secret: null }; + } + + declassify(disclosed: Jsonify, _secret: Jsonify>) { + const result: Partial> = {}; + + for (const key of this.keys) { + result[key] = disclosed[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index 8a2c1e38f5c..f6932f01dc1 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -2,11 +2,18 @@ * include structuredClone in test environment. * @jest-environment ../../../../shared/test.environment.ts */ -import { of, firstValueFrom } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; -import { distinctIfShallowMatch, reduceCollection } from "./rx"; +import { + anyComplete, + distinctIfShallowMatch, + on, + ready, + reduceCollection, + withLatestReady, +} from "./rx"; describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( @@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => { expect(result).toEqual([{ foo: true, bar: true }]); }); }); + +describe("anyComplete", () => { + it("emits true when its input completes", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("completes when its input is already complete", () => { + const input = new Subject(); + input.complete(); + + let completed = false; + anyComplete(input).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + it("completes when any input completes", () => { + const input$ = new Subject(); + const completing$ = new Subject(); + + let completed = false; + anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); + completing$.complete(); + + expect(completed).toBe(true); + }); + + it("ignores emissions", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.next(1); + input$.next(2); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("forwards errors", () => { + const input$ = new Subject(); + const expected = { some: "error" }; + + let error = null; + anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); + input$.error(expected); + + expect(error).toEqual(expected); + }); +}); + +describe("ready", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(ready(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("suppresses source emissions until all watches emit", () => { + const watchA$ = new Subject(); + const watchB$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready([watchA$, watchB$])); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // preconditions: no emissions + source$.next(1); + expect(results).toEqual([]); + watchA$.next(); + expect(results).toEqual([]); + + watchB$.next(); + + expect(results).toEqual([1]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next(); + + expect(results).toEqual([2]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + source$.next(2); + + expect(results).toEqual([1, 2]); + }); + + it("ignores repeated watch emissions", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); +}); + +describe("withLatestReady", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(withLatestReady(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next("watch"); + + expect(results).toEqual([[1, "watch"]]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next("watch"); + + expect(results).toEqual([[2, "watch"]]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("watch"); + source$.next(1); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("appends the latest watch emission", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("ignored"); + watch$.next("watch"); + source$.next(1); + watch$.next("ignored"); + watch$.next("watch"); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); +}); + +describe("on", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: on$ should be cold + const on$ = source$.pipe(on(watch$)); + expect(connected).toBeFalsy(); + + on$.subscribe(); + + expect(connected).toBeTruthy(); + }); + + it("suppresses source emissions until `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + // precondition: on$ should be cold + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("repeats source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + watch$.next(); + + expect(results).toEqual([1, 1]); + }); + + it("updates source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("emits a value when `on` emits before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("ignores repeated `on` emissions before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits only the latest source emission when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + + source$.next(2); + source$.next(3); + watch$.next(); + + expect(results).toEqual([1, 3]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("completes when its watch completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + watch$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); +}); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d2c5747a882..d5d0b499ff2 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -1,4 +1,21 @@ -import { map, distinctUntilChanged, OperatorFunction } from "rxjs"; +import { + map, + distinctUntilChanged, + OperatorFunction, + Observable, + ignoreElements, + endWith, + race, + pipe, + connect, + ReplaySubject, + concat, + zip, + first, + takeUntil, + withLatestFrom, + concatMap, +} from "rxjs"; /** * An observable operator that reduces an emitted collection to a single object, @@ -36,3 +53,109 @@ export function distinctIfShallowMatch(): OperatorFunction { return isDistinct; }); } + +/** Create an observable that, once subscribed, emits `true` then completes when + * any input completes. If an input is already complete when the subscription + * occurs, it emits immediately. + * @param watch$ the observable(s) to watch for completion; if an array is passed, + * null and undefined members are ignored. If `watch$` is empty, `anyComplete` + * will never complete. + * @returns An observable that emits `true` when any of its inputs + * complete. The observable forwards the first error from its input. + * @remarks This method is particularly useful in combination with `takeUntil` and + * streams that are not guaranteed to complete on their own. + */ +export function anyComplete(watch$: Observable | Observable[]): Observable { + if (Array.isArray(watch$)) { + const completes$ = watch$ + .filter((w$) => !!w$) + .map((w$) => w$.pipe(ignoreElements(), endWith(true))); + const completed$ = race(completes$); + return completed$; + } else { + return watch$.pipe(ignoreElements(), endWith(true)); + } +} + +/** + * Create an observable that delays the input stream until all watches have + * emitted a value. The watched values are not included in the source stream. + * The last emission from the source is output when all the watches have + * emitted at least once. + * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, + * `ready` will never emit. + * @returns An observable that emits when the source stream emits. The observable + * errors if one of its watches completes before emitting. It also errors if one + * of its watches errors. + */ +export function ready(watch$: Observable | Observable[]) { + const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; + return pipe( + connect>((source$) => { + // this subscription is safe because `source$` connects only after there + // is an external subscriber. + const source = new ReplaySubject(1); + source$.subscribe(source); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` are ready. If that occurs + // after `source$` is hot, then the replay subject sends the last-captured + // emission through immediately. Otherwise, `ready` waits for the next + // emission + return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe( + takeUntil(anyComplete(source)), + ); + }), + ); +} + +export function withLatestReady( + watch$: Observable, +): OperatorFunction { + return connect((source$) => { + // these subscriptions are safe because `source$` connects only after there + // is an external subscriber. + const source = new ReplaySubject(1); + source$.subscribe(source); + const watch = new ReplaySubject(1); + watch$.subscribe(watch); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` are ready. If that occurs + // after `source$` is hot, then the replay subject sends the last-captured + // emission through immediately. Otherwise, `ready` waits for the next + // emission + return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe( + withLatestFrom(watch), + takeUntil(anyComplete(source)), + ); + }); +} + +/** + * Create an observable that emits the latest value of the source stream + * when `watch$` emits. If `watch$` emits before the stream emits, then + * an emission occurs as soon as a value becomes ready. + * @param watch$ the observable that triggers emissions + * @returns An observable that emits when `watch$` emits. The observable + * errors if its source stream errors. It also errors if `on` errors. It + * completes if its watch completes. + * + * @remarks This works like `audit`, but it repeats emissions when + * watch$ fires. + */ +export function on(watch$: Observable) { + return pipe( + connect>((source$) => { + const source = new ReplaySubject(1); + source$.subscribe(source); + + return watch$ + .pipe( + ready(source), + concatMap(() => source.pipe(first())), + ) + .pipe(takeUntil(anyComplete(source))); + }), + ); +} diff --git a/libs/common/src/tools/state/classified-format.ts b/libs/common/src/tools/state/classified-format.ts index 93147a0fb53..26aca0197c5 100644 --- a/libs/common/src/tools/state/classified-format.ts +++ b/libs/common/src/tools/state/classified-format.ts @@ -17,3 +17,9 @@ export type ClassifiedFormat = { */ readonly disclosed: Jsonify; }; + +export function isClassifiedFormat( + value: any, +): value is ClassifiedFormat { + return "id" in value && "secret" in value && "disclosed" in value; +} diff --git a/libs/common/src/tools/state/identity-state-constraint.ts b/libs/common/src/tools/state/identity-state-constraint.ts index ff7712b9091..df33dad543a 100644 --- a/libs/common/src/tools/state/identity-state-constraint.ts +++ b/libs/common/src/tools/state/identity-state-constraint.ts @@ -1,4 +1,11 @@ -import { Constraints, StateConstraints } from "../types"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { + Constraints, + DynamicStateConstraints, + StateConstraints, + SubjectConstraints, +} from "../types"; // The constraints type shares the properties of the state, // but never has any members @@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy(Object.freeze({}), { }); /** A constraint that does nothing. */ -export class IdentityConstraint implements StateConstraints { +export class IdentityConstraint + implements StateConstraints, DynamicStateConstraints +{ /** Instantiate the identity constraint */ constructor() {} readonly constraints: Readonly> = EMPTY_CONSTRAINTS; + calibrate() { + return this; + } + adjust(state: State) { return state; } + fix(state: State) { return state; } } + +/** Emits a constraint that does not alter the input state. */ +export function unconstrained$(): Observable> { + const identity = new IdentityConstraint(); + const constraints$ = new BehaviorSubject(identity); + + return constraints$; +} diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts new file mode 100644 index 00000000000..88365d5cbd1 --- /dev/null +++ b/libs/common/src/tools/state/object-key.ts @@ -0,0 +1,53 @@ +import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as a type +import type { StateDefinition } from "../../platform/state/state-definition"; + +import { ClassifiedFormat } from "./classified-format"; +import { Classifier } from "./classifier"; + +/** A key for storing JavaScript objects (`{ an: "example" }`) + * in a UserStateSubject. + */ +// FIXME: promote to class: `ObjectConfiguration`. +// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix` +// From `UserStateSubject`. `UserStateSubject` keeps `classify` and +// `declassify`. The class should also include serialization +// facilities (to be used in place of JSON.parse/stringify) in it's +// options. Also allow swap between "classifier" and "classification"; the +// latter is a list of properties/arguments to the specific classifier in-use. +export type ObjectKey> = { + target: "object"; + key: string; + state: StateDefinition; + classifier: Classifier; + format: "plain" | "classified"; + options: UserKeyDefinitionOptions; +}; + +export function isObjectKey(key: any): key is ObjectKey { + return key.target === "object" && "format" in key && "classifier" in key; +} + +export function toUserKeyDefinition( + key: ObjectKey, +) { + if (key.format === "plain") { + const plain = new UserKeyDefinition(key.state, key.key, key.options); + + return plain; + } else if (key.format === "classified") { + const classified = new UserKeyDefinition>( + key.state, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as ClassifiedFormat, + clearOn: key.options.clearOn, + }, + ); + + return classified; + } else { + throw new Error(`unknown format: ${key.format}`); + } +} diff --git a/libs/common/src/tools/state/state-constraints-dependency.ts b/libs/common/src/tools/state/state-constraints-dependency.ts index 66bac636bd7..427ff42e7a4 100644 --- a/libs/common/src/tools/state/state-constraints-dependency.ts +++ b/libs/common/src/tools/state/state-constraints-dependency.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { DynamicStateConstraints, StateConstraints } from "../types"; +import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types"; /** A pattern for types that depend upon a dynamic set of constraints. * @@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types"; * last-emitted constraints. If `constraints$` completes, the consumer should * continue using the last-emitted constraints. */ -export type StateConstraintsDependency = { +export type SubjectConstraintsDependency = { /** A stream that emits constraints when subscribed and when the * constraints change. The stream should not emit `null` or * `undefined`. */ - constraints$: Observable | DynamicStateConstraints>; + constraints$: Observable>; }; /** Returns `true` if the input constraint is a `DynamicStateConstraints`. diff --git a/libs/common/src/tools/state/user-state-subject-dependencies.ts b/libs/common/src/tools/state/user-state-subject-dependencies.ts index 7f36ab7cae8..0ba842334bf 100644 --- a/libs/common/src/tools/state/user-state-subject-dependencies.ts +++ b/libs/common/src/tools/state/user-state-subject-dependencies.ts @@ -1,15 +1,23 @@ -import { Simplify } from "type-fest"; +import { RequireExactlyOne, Simplify } from "type-fest"; -import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; +import { + Dependencies, + SingleUserDependency, + SingleUserEncryptorDependency, + WhenDependency, +} from "../dependencies"; -import { StateConstraintsDependency } from "./state-constraints-dependency"; +import { SubjectConstraintsDependency } from "./state-constraints-dependency"; /** dependencies accepted by the user state subject */ export type UserStateSubjectDependencies = Simplify< - SingleUserDependency & + RequireExactlyOne< + SingleUserDependency & SingleUserEncryptorDependency, + "singleUserEncryptor$" | "singleUserId$" + > & Partial & Partial> & - Partial> & { + Partial> & { /** Compute the next stored value. If this is not set, values * provided to `next` unconditionally override state. * @param current the value stored in state diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 73971da4ef9..9f5475df9de 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -1,14 +1,50 @@ import { BehaviorSubject, of, Subject } from "rxjs"; +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { UserBound } from "../dependencies"; +import { PrivateClassifier } from "../private-classifier"; import { StateConstraints } from "../types"; +import { ClassifiedFormat } from "./classified-format"; +import { ObjectKey } from "./object-key"; +import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; type TestType = { foo: string }; +const SomeKey = new UserKeyDefinition(GENERATOR_DISK, "TestKey", { + deserializer: (d) => d as TestType, + clearOn: [], +}); + +const SomeObjectKey = { + target: "object", + key: "TestObjectKey", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { + deserializer: (d) => d as TestType, + clearOn: ["logout"], + }, +} satisfies ObjectKey; + +const SomeEncryptor: UserEncryptor = { + userId: SomeUser, + + encrypt(secret) { + const tmp: any = secret; + return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any); + }, + + decrypt(secret) { + const tmp: any = JSON.parse(secret.encryptedString); + return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any); + }, +}; function fooMaxLength(maxLength: number): StateConstraints { return Object.freeze({ @@ -43,7 +79,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -65,7 +105,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -79,11 +123,35 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalledTimes(1); }); + it("ignores repeated singleUserEncryptor$ emissions", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const nextValue = jest.fn((_, next) => next); + const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null }); + const subject = new UserStateSubject(SomeKey, () => state, { + nextValue, + singleUserEncryptor$, + }); + + // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously + subject.next({ foo: "next" }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledTimes(1); + }); + it("waits for constraints$", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -91,13 +159,28 @@ describe("UserStateSubject", () => { expect(initResult).toEqual({ foo: "ini" }); }); + + it("waits for singleUserEncryptor$", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: {} }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const tracker = new ObservableTracker(subject); + + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + const [initResult] = await tracker.pauseUntilReceived(1); + + expect(initResult).toEqual({ foo: "decrypt(init)" }); + }); }); describe("next", () => { it("emits the next value", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -114,7 +197,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe((value) => { @@ -132,7 +215,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -147,7 +230,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate, dependencies$: of(dependencyValue), @@ -165,7 +248,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -183,7 +266,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => false); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); subject.next({ foo: "next" }); await awaitAsync(); @@ -200,7 +283,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -215,7 +298,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue, dependencies$: of(dependencyValue), @@ -236,7 +319,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -253,7 +340,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(false); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -265,42 +356,52 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalled(); }); - it("waits to evaluate nextValue until singleUserId$ emits", async () => { - // this test looks for `nextValue` because a subscription isn't necessary for + it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => { + // this test looks for `nextMock` because a subscription isn't necessary for // the subject to update. const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new Subject(); - const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); + // precondition: subject doesn't update after `next` const nextVal: TestType = { foo: "next" }; subject.next(nextVal); await awaitAsync(); - expect(nextValue).not.toHaveBeenCalled(); + expect(state.nextMock).not.toHaveBeenCalled(); + singleUserId$.next(SomeUser); await awaitAsync(); - expect(nextValue).toHaveBeenCalled(); + expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" }); }); - it("applies constraints$ on init", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); - const [result] = await tracker.pauseUntilReceived(1); + // precondition: subject doesn't update after `next` + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + expect(state.nextMock).not.toHaveBeenCalled(); - expect(result).toEqual({ foo: "in" }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + await awaitAsync(); + + const encrypted = { foo: "encrypt(next)" }; + expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null }); }); it("applies dynamic constraints", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -311,24 +412,11 @@ describe("UserStateSubject", () => { expect(actual).toEqual({ foo: "" }); }); - it("applies constraints$ on constraints$ emission", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); - - constraints$.next(fooMaxLength(1)); - const [, result] = await tracker.pauseUntilReceived(2); - - expect(result).toEqual({ foo: "i" }); - }); - it("applies constraints$ on next", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); subject.next({ foo: "next" }); @@ -341,7 +429,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -355,13 +443,17 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const results: any[] = []; + subject.subscribe((r) => { + results.push(r); + }); subject.next({ foo: "next" }); constraints$.next(fooMaxLength(3)); + await awaitAsync(); // `init` is also waiting and is processed before `next` - const [, nextResult] = await tracker.pauseUntilReceived(2); + const [, nextResult] = results; expect(nextResult).toEqual({ foo: "nex" }); }); @@ -370,7 +462,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.error({ some: "error" }); @@ -384,7 +476,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.complete(); @@ -399,7 +491,7 @@ describe("UserStateSubject", () => { it("emits errors", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "error" }; let actual: TestType = null; @@ -418,7 +510,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe({ @@ -437,7 +529,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -457,7 +549,7 @@ describe("UserStateSubject", () => { it("emits completes", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -475,7 +567,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -496,7 +588,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let timesRun = 0; subject.subscribe({ @@ -513,11 +605,36 @@ describe("UserStateSubject", () => { }); describe("subscribe", () => { + it("applies constraints$ on init", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + const [result] = await tracker.pauseUntilReceived(1); + + expect(result).toEqual({ foo: "in" }); + }); + + it("applies constraints$ on constraints$ emission", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(1)); + const [, result] = await tracker.pauseUntilReceived(2); + + expect(result).toEqual({ foo: "i" }); + }); + it("completes when singleUserId$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -531,12 +648,32 @@ describe("UserStateSubject", () => { expect(actual).toBeTruthy(); }); + it("completes when singleUserId$ completes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + singleUserEncryptor$.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + it("completes when when$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); let actual = false; subject.subscribe({ @@ -557,7 +694,7 @@ describe("UserStateSubject", () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const errorUserId = "error" as UserId; let error = false; @@ -572,11 +709,32 @@ describe("UserStateSubject", () => { expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); }); + it("errors when singleUserEncryptor$ changes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const errorUserId = "error" as UserId; + + let error = false; + subject.subscribe({ + error: (e: unknown) => { + error = e as any; + }, + }); + singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); + await awaitAsync(); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); + }); + it("errors when singleUserId$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected = { error: "description" }; let actual = false; @@ -591,12 +749,31 @@ describe("UserStateSubject", () => { expect(actual).toEqual(expected); }); + it("errors when singleUserEncryptor$ errors", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ }); + const expected = { error: "description" }; + + let actual = false; + subject.subscribe({ + error: (e: unknown) => { + actual = e as any; + }, + }); + singleUserEncryptor$.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + it("errors when when$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); const expected = { error: "description" }; let actual = false; @@ -616,7 +793,7 @@ describe("UserStateSubject", () => { it("returns the userId to which the subject is bound", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new Subject(); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); expect(subject.userId).toEqual(SomeUser); }); @@ -626,7 +803,7 @@ describe("UserStateSubject", () => { it("emits the next value with an empty constraint", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -642,7 +819,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); subject.complete(); @@ -657,7 +834,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(1); const emission = tracker.expectEmission(); @@ -673,7 +850,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -690,7 +867,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(2); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const emission = tracker.expectEmission(); @@ -705,7 +882,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); constraints$.next(expected); @@ -722,7 +899,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); @@ -740,7 +917,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.error({ some: "error" }); @@ -756,7 +933,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.complete(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 61a9e87c686..89f19ac3c73 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -5,15 +5,10 @@ import { ReplaySubject, filter, map, - Subject, takeUntil, pairwise, - combineLatest, distinctUntilChanged, BehaviorSubject, - race, - ignoreElements, - endWith, startWith, Observable, Subscription, @@ -22,16 +17,32 @@ import { combineLatestWith, catchError, EMPTY, + concatMap, + OperatorFunction, + pipe, + first, + withLatestFrom, + scan, + skip, } from "rxjs"; -import { SingleUserState } from "@bitwarden/common/platform/state"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; -import { WithConstraints } from "../types"; +import { UserBound } from "../dependencies"; +import { anyComplete, ready, withLatestReady } from "../rx"; +import { Constraints, SubjectConstraints, WithConstraints } from "../types"; -import { IdentityConstraint } from "./identity-state-constraint"; +import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; +import { unconstrained$ } from "./identity-state-constraint"; +import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; +import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; +type Constrained = { constraints: Readonly>; state: State }; + /** * Adapt a state provider to an rxjs subject. * @@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies" * @template State the state stored by the subject * @template Dependencies use-specific dependencies provided by the user. */ -export class UserStateSubject +export class UserStateSubject< + State extends object, + Secret = State, + Disclosed = never, + Dependencies = null, + > extends Observable implements SubjectLike { /** - * Instantiates the user state subject - * @param state the backing store of the subject - * @param dependencies tailor the subject's behavior for a particular + * Instantiates the user state subject bound to a persistent backing store + * @param key identifies the persistent backing store + * @param getState creates a persistent backing store using a key + * @param context tailor the subject's behavior for a particular * purpose. * @param dependencies.when$ blocks updates to the state subject until * this becomes true. When this occurs, only the last-received update @@ -61,93 +78,306 @@ export class UserStateSubject * is available. */ constructor( - private state: SingleUserState, - private dependencies: UserStateSubjectDependencies, + private key: UserKeyDefinition | ObjectKey, + getState: (key: UserKeyDefinition) => SingleUserState, + private context: UserStateSubjectDependencies, ) { super(); + if (isObjectKey(this.key)) { + // classification and encryption only supported with `ObjectKey` + this.objectKey = this.key; + this.stateKey = toUserKeyDefinition(this.key); + this.state = getState(this.stateKey); + } else { + // raw state access granted with `UserKeyDefinition` + this.objectKey = null; + this.stateKey = this.key as UserKeyDefinition; + this.state = getState(this.stateKey); + } + // normalize dependencies - const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( - distinctUntilChanged(), - ); - const userIdAvailable$ = this.dependencies.singleUserId$.pipe( - startWith(state.userId), - pairwise(), - map(([expectedUserId, actualUserId]) => { - if (expectedUserId === actualUserId) { - return true; - } else { - throw { expectedUserId, actualUserId }; - } - }), - distinctUntilChanged(), - ); - const constraints$ = ( - this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint()) - ).pipe( - // FIXME: this should probably log that an error occurred - catchError(() => EMPTY), - ); + const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged()); - // normalize input in case this `UserStateSubject` is not the only - // observer of the backing store - const input$ = combineLatest([this.input, constraints$]).pipe( - map(([input, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints; - const state = calibration.adjust(input); - return state; - }), - ); + // manage dependencies through replay subjects since `UserStateSubject` + // reads them in multiple places + const encryptor$ = new ReplaySubject(1); + const { singleUserId$, singleUserEncryptor$ } = this.context; + this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$); - // when the output subscription completes, its last-emitted value - // loops around to the input for finalization - const finalize$ = this.pipe( - last(), - combineLatestWith(constraints$), - map(([output, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints; - const state = calibration.fix(output); - return state; - }), - ); - const updates$ = concat(input$, finalize$); + const constraints$ = new ReplaySubject>(1); + (this.context.constraints$ ?? unconstrained$()) + .pipe( + // FIXME: this should probably log that an error occurred + catchError(() => EMPTY), + ) + .subscribe(constraints$); - // observe completion - const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); - const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true)); - const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); - const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); + const dependencies$ = new ReplaySubject(1); + if (this.context.dependencies$) { + this.context.dependencies$.subscribe(dependencies$); + } else { + dependencies$.next(null); + } // wire output before input so that output normalizes the current state // before any `next` value is processed this.outputSubscription = this.state.state$ - .pipe( - combineLatestWith(constraints$), - map(([rawState, constraints]) => { - const calibration = isDynamic(constraints) - ? constraints.calibrate(rawState) - : constraints; - const state = calibration.adjust(rawState); - return { - constraints: calibration.constraints, - state, - }; - }), - ) + .pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$))) .subscribe(this.output); - this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$]) + + const last$ = new ReplaySubject(1); + this.output .pipe( - filter(([_, when]) => when), - map(([state]) => state), - takeUntil(completion$), + last(), + map((o) => o.state), ) + .subscribe(last$); + + // the update stream simulates the stateProvider's "shouldUpdate" + // functionality & applies policy + const updates$ = concat( + this.input.pipe( + this.when(when$), + this.adjust(withLatestReady(constraints$)), + this.prepareUpdate(this, dependencies$), + ), + // when the output subscription completes, its last-emitted value + // loops around to the input for finalization + last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)), + ); + + // classification/encryption bound to the input subscription's lifetime + // to ensure that `fix` has access to the encryptor key + // + // FIXME: this should probably timeout when a lock occurs + this.inputSubscription = updates$ + .pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$]))) .subscribe({ - next: (r) => this.onNext(r), + next: (state) => this.onNext(state), error: (e: unknown) => this.onError(e), complete: () => this.onComplete(), }); } + private stateKey: UserKeyDefinition; + private objectKey: ObjectKey; + + private encryptor( + singleUserEncryptor$: Observable | UserId>, + ): Observable { + return singleUserEncryptor$.pipe( + // normalize inputs + map((maybe): UserBound<"encryptor", UserEncryptor> => { + if (typeof maybe === "object" && "encryptor" in maybe) { + return maybe; + } else if (typeof maybe === "string") { + return { encryptor: null, userId: maybe as UserId }; + } else { + throw new Error(`Invalid encryptor input received for ${this.key.key}.`); + } + }), + // fail the stream if the state desyncs from the bound userId + startWith({ userId: this.state.userId, encryptor: null } as UserBound< + "encryptor", + UserEncryptor + >), + pairwise(), + map(([expected, actual]) => { + if (expected.userId === actual.userId) { + return actual; + } else { + throw { + expectedUserId: expected.userId, + actualUserId: actual.userId, + }; + } + }), + // reduce emissions to when encryptor changes + distinctUntilChanged(), + map(({ encryptor }) => encryptor), + ); + } + + private when(when$: Observable): OperatorFunction { + return pipe( + combineLatestWith(when$.pipe(distinctUntilChanged())), + filter(([_, when]) => !!when), + map(([input]) => input), + ); + } + + private prepareUpdate( + init$: Observable, + dependencies$: Observable, + ): OperatorFunction, State> { + return (input$) => + concat( + // `init$` becomes the accumulator for `scan` + init$.pipe( + first(), + map((init) => [init, null] as const), + ), + input$.pipe( + map((constrained) => constrained.state), + withLatestFrom(dependencies$), + ), + ).pipe( + // scan only emits values that can cause updates + scan(([prev], [pending, dependencies]) => { + const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true; + if (shouldUpdate) { + // actual update + const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending; + return [next, dependencies]; + } else { + // false update + return [prev, null]; + } + }), + // the first emission primes `scan`s aggregator + skip(1), + map(([state]) => state), + + // clean up false updates + distinctUntilChanged(), + ); + } + + private adjust( + withConstraints: OperatorFunction]>, + ): OperatorFunction> { + return pipe( + // how constraints are blended with incoming emissions varies: + // * `output` needs to emit when constraints update + // * `input` needs to wait until a message flows through the pipe + withConstraints, + map(([loadedState, constraints]) => { + // bypass nulls + if (!loadedState) { + return { + constraints: {} as Constraints, + state: null, + } satisfies Constrained; + } + + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const adjusted = calibration.adjust(loadedState); + + return { + constraints: calibration.constraints, + state: adjusted, + }; + }), + ); + } + + private fix( + constraints$: Observable>, + ): OperatorFunction> { + return pipe( + combineLatestWith(constraints$), + map(([loadedState, constraints]) => { + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const fixed = calibration.fix(loadedState); + + return { + constraints: calibration.constraints, + state: fixed, + }; + }), + ); + } + + private declassify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support + if (!this.objectKey || this.objectKey.format === "plain") { + return (input$) => input$ as Observable; + } + + // if the key supports encryption, enable encryptor support + if (this.objectKey && this.objectKey.format === "classified") { + return pipe( + combineLatestWith(encryptor$), + concatMap(async ([input, encryptor]) => { + // pass through null values + if (input === null || input === undefined) { + return null; + } + + // fail fast if the format is incorrect + if (!isClassifiedFormat(input)) { + throw new Error(`Cannot declassify ${this.key.key}; unknown format.`); + } + + // decrypt classified data + const { secret, disclosed } = input; + const encrypted = EncString.fromJSON(secret); + const decryptedSecret = await encryptor.decrypt(encrypted); + + // assemble into proper state + const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); + const state = this.objectKey.options.deserializer(declassified); + + return state; + }), + ); + } + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + + private classify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support; `encryptor` is + // readied to preserve `dependencies.singleUserId$` emission contract + if (!this.objectKey || this.objectKey.format === "plain") { + return pipe( + ready(encryptor$), + map((input) => input as unknown), + ); + } + + // if the key supports encryption, enable encryptor support + if (this.objectKey && this.objectKey.format === "classified") { + return pipe( + withLatestReady(encryptor$), + concatMap(async ([input, encryptor]) => { + // fail fast if there's no value + if (input === null || input === undefined) { + return null; + } + + // split data by classification level + const serialized = JSON.parse(JSON.stringify(input)); + const classified = this.objectKey.classifier.classify(serialized); + + // protect data + const encrypted = await encryptor.encrypt(classified.secret); + const secret = JSON.parse(JSON.stringify(encrypted)); + + // wrap result in classified format envelope for storage + const envelope = { + id: null as void, + secret, + disclosed: classified.disclosed, + } satisfies ClassifiedFormat; + + // deliberate type erasure; the type is restored during `declassify` + return envelope as unknown; + }), + ); + } + + // FIXME: add "encrypted" format --> key contains encryption logic + // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + /** The userId to which the subject is bound. */ get userId() { @@ -177,7 +407,8 @@ export class UserStateSubject // using subjects to ensure the right semantics are followed; // if greater efficiency becomes desirable, consider implementing // `SubjectLike` directly - private input = new Subject(); + private input = new ReplaySubject(1); + private state: SingleUserState; private readonly output = new ReplaySubject>(1); /** A stream containing settings and their last-applied constraints. */ @@ -188,25 +419,8 @@ export class UserStateSubject private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; - private onNext(value: State) { - const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next); - const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true); - - this.state - .update( - (state, dependencies) => { - const next = nextValue(state, value, dependencies); - return next; - }, - { - shouldUpdate(current, dependencies) { - const update = shouldUpdate(current, value, dependencies); - return update; - }, - combineLatestWith: this.dependencies.dependencies$, - }, - ) - .catch((e: any) => this.onError(e)); + private onNext(value: unknown) { + this.state.update(() => value).catch((e: any) => this.onError(e)); } private onError(value: any) { @@ -232,8 +446,8 @@ export class UserStateSubject private dispose() { if (!this.isDisposed) { // clean up internal subscriptions - this.inputSubscription.unsubscribe(); - this.outputSubscription.unsubscribe(); + this.inputSubscription?.unsubscribe(); + this.outputSubscription?.unsubscribe(); this.inputSubscription = null; this.outputSubscription = null; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index ec1903e6225..9b746924278 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -1,5 +1,7 @@ import { Simplify } from "type-fest"; +import { IntegrationId } from "./integration"; + /** Constraints that are shared by all primitive field types */ type PrimitiveConstraint = { /** `true` indicates the field is required; otherwise the field is optional */ @@ -129,6 +131,8 @@ export type StateConstraints = { fix: (state: State) => State; }; +export type SubjectConstraints = StateConstraints | DynamicStateConstraints; + /** Options that provide contextual information about the application state * when a generator is invoked. */ @@ -144,4 +148,7 @@ export type VaultItemRequest = { /** Options that provide contextual information about the application state * when a generator is invoked. */ -export type GenerationRequest = Partial; +export type GenerationRequest = Partial & + Partial<{ + integration: IntegrationId | null; + }>; diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 53df58c8480..4c9fb9e7e49 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -3,7 +3,7 @@ fullWidth class="tw-mb-4" [selected]="(root$ | async).nav" - (selectedChange)="onRootChanged($event)" + (selectedChange)="onRootChanged({ nav: $event })" attr.aria-label="{{ 'type' | i18n }}" > @@ -35,23 +35,23 @@ -
{{ "options" | i18n }}
+

{{ "options" | i18n }}

- + {{ "type" | i18n }} @@ -60,18 +60,29 @@ }} +
+ + {{ "service" | i18n }} + + +
+ diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 86093beecd6..25aff97f16c 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -2,11 +2,12 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, - concat, + catchError, + combineLatest, + combineLatestWith, distinctUntilChanged, filter, map, - of, ReplaySubject, Subject, switchMap, @@ -16,25 +17,32 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; import { Option } from "@bitwarden/components/src/select/option"; import { + AlgorithmInfo, CredentialAlgorithm, CredentialCategory, - CredentialGeneratorInfo, CredentialGeneratorService, GeneratedCredential, Generators, + getForwarderConfiguration, isEmailAlgorithm, + isForwarderIntegration, isPasswordAlgorithm, + isSameAlgorithm, isUsernameAlgorithm, - PasswordAlgorithm, + toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; -/** root category that drills into username and email categories */ +// constants used to identify navigation selections that are not +// generator algorithms const IDENTIFIER = "identifier"; -/** options available for the top-level navigation */ -type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; +const FORWARDER = "forwarder"; +const NONE_SELECTED = "none"; @Component({ selector: "tools-credential-generator", @@ -43,6 +51,8 @@ type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; export class CredentialGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private toastService: ToastService, + private logService: LogService, private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, @@ -59,59 +69,25 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { @Output() readonly onGenerated = new EventEmitter(); - protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ + protected root$ = new BehaviorSubject<{ nav: string }>({ nav: null, }); - /** - * Emits the copy button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("copyPassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("copyPassphrase"); - } - - return this.i18nService.t("copyUsername"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("generatePassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("generatePassphrase"); - } - - return this.i18nService.t("generateUsername"); - }), - ); - - protected onRootChanged(nav: RootNavValue) { + protected onRootChanged(value: { nav: string }) { // prevent subscription cycle - if (this.root$.value.nav !== nav) { + if (this.root$.value.nav !== value.nav) { this.zone.run(() => { - this.root$.next({ nav }); + this.root$.next(value); }); } } protected username = this.formBuilder.group({ - nav: [null as CredentialAlgorithm], + nav: [null as string], + }); + + protected forwarder = this.formBuilder.group({ + nav: [null as string], }); async ngOnInit() { @@ -130,16 +106,29 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.generatorService .algorithms$(["email", "username"], { userId$: this.userId$ }) .pipe( - map((algorithms) => this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.usernameOptions$); + .subscribe(([usernames, forwarders]) => { + this.usernameOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.generatorService .algorithms$("password", { userId$: this.userId$ }) .pipe( map((algorithms) => { - const options = this.toOptions(algorithms) as Option[]; + const options = this.toOptions(algorithms); options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); return options; }), @@ -149,7 +138,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -162,7 +151,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a.category), + map((a) => a?.category), distinctUntilChanged(), takeUntil(this.destroyed), ) @@ -177,7 +166,22 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -189,35 +193,116 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); }); - // assume the last-visible generator algorithm is the user's preferred one - const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeRoot$ = new Subject(); + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + this.root$ .pipe( - filter(({ nav }) => !!nav), - switchMap((root) => { - if (root.nav === IDENTIFIER) { - return concat(of(this.username.value), this.username.valueChanges); + map( + (root): CascadeValue => + root.nav === IDENTIFIER + ? { nav: root.nav } + : { nav: root.nav, algorithm: JSON.parse(root.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeRoot$); + + this.username.valueChanges + .pipe( + map( + (username): CascadeValue => + username.nav === FORWARDER + ? { nav: username.nav } + : { nav: username.nav, algorithm: JSON.parse(username.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeIdentifier$); + + this.forwarder.valueChanges + .pipe( + map( + (forwarder): CascadeValue => + forwarder.nav === NONE_SELECTED + ? { nav: forwarder.nav } + : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const showForwarder = !root.algorithm && !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const selection = root.algorithm ?? username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); } else { - return of(root as { nav: PasswordAlgorithm }); + return null; } }), - filter(({ nav }) => !!nav), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // assume the last-selected generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.algorithm$ + .pipe( + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ nav: algorithm }, preference]) => { + .subscribe(([algorithm, preference]) => { function setPreference(category: CredentialCategory) { const p = preference[category]; - p.algorithm = algorithm; + p.algorithm = algorithm.id; p.updated = new Date(); } // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` - if (isEmailAlgorithm(algorithm)) { + if (isEmailAlgorithm(algorithm.id)) { setPreference("email"); - } else if (isUsernameAlgorithm(algorithm)) { + } else if (isUsernameAlgorithm(algorithm.id)) { setPreference("username"); - } else if (isPasswordAlgorithm(algorithm)) { + } else if (isPasswordAlgorithm(algorithm.id)) { setPreference("password"); } else { return; @@ -227,34 +312,74 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { - // the last preference set by the user "wins" - const userNav = email.updated > username.updated ? email : username; - const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm; - const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm; - - // update navigation; break subscription loop - this.onRootChanged(rootNav); - this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false }); - - // load algorithm metadata - const algorithm = this.generatorService.algorithm(credentialType); - - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username, password }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + const rootNav = + usernamePref.updated > password.updated + ? IDENTIFIER + : JSON.stringify(password.algorithm); + + // construct cascade metadata + const cascade = { + root: { + selection: { nav: rootNav }, + active: { + nav: rootNav, + algorithm: rootNav === IDENTIFIER ? null : password.algorithm, + } as CascadeValue, + }, + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ root, username, forwarder }) => { + // update navigation; break subscription loop + this.onRootChanged(root.selection); + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeRoot$.next(root.active); + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -278,20 +403,61 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { case "passphrase": return this.generatorService.generate$(Generators.passphrase, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + const generator = this.generatorService.generate$(configuration, dependencies); + return generator; + } + + throw new Error(`Invalid generator type: "${type}"`); } - /** Lists the credential types of the username algorithm box. */ - protected usernameOptions$ = new BehaviorSubject[]>([]); + /** Lists the top-level credential types supported by the component. + * @remarks This is string-typed because angular doesn't support + * structural equality for objects, which prevents `CredentialAlgorithm` + * from being selectable within a dropdown when its value contains a + * `ForwarderIntegration`. + */ + protected rootOptions$ = new BehaviorSubject[]>([]); - /** Lists the top-level credential types supported by the component. */ - protected rootOptions$ = new BehaviorSubject[]>([]); + /** Lists the credential types of the username algorithm box. */ + protected usernameOptions$ = new BehaviorSubject[]>([]); + + /** Lists the credential types of the username algorithm box. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + + /** Tracks forwarder control visibility */ + protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -308,10 +474,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: algorithm.name, })); return options; diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html new file mode 100644 index 00000000000..64566fa9562 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -0,0 +1,16 @@ +
+ + {{ "forwarderDomainName" | i18n }} + + {{ "forwarderDomainNameHint" | i18n }} + + + {{ "apiKey" | i18n }} + + + + + {{ "selfHostBaseUrl" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts new file mode 100644 index 00000000000..a1e6c7acfd8 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -0,0 +1,195 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + concatMap, + map, + ReplaySubject, + skip, + Subject, + switchAll, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorConfiguration, + CredentialGeneratorService, + getForwarderConfiguration, + NoPolicy, + toCredentialGeneratorConfiguration, +} from "@bitwarden/generator-core"; + +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + domain: "domain", + token: "token", + baseUrl: "baseUrl", +}); + +/** Options group for forwarder integrations */ +@Component({ + selector: "tools-forwarder-settings", + templateUrl: "forwarder-settings.component.html", +}) +export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + @Input({ required: true }) + forwarder: IntegrationId; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + [Controls.domain]: [""], + [Controls.token]: [""], + [Controls.baseUrl]: [""], + }); + + private forwarderId$ = new ReplaySubject(1); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + + const forwarder$ = new ReplaySubject>(1); + this.forwarderId$ + .pipe( + map((id) => getForwarderConfiguration(id)), + // type erasure necessary because the configuration properties are + // determined dynamically at runtime + // FIXME: this can be eliminated by unifying the forwarder settings types; + // see `ForwarderConfiguration<...>` for details. + map((forwarder) => toCredentialGeneratorConfiguration(forwarder)), + takeUntil(this.destroyed$), + ) + .subscribe((forwarder) => { + this.displayDomain = forwarder.request.includes("domain"); + this.displayToken = forwarder.request.includes("token"); + this.displayBaseUrl = forwarder.request.includes("baseUrl"); + + forwarder$.next(forwarder); + }); + + const settings$$ = forwarder$.pipe( + concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })), + ); + + // bind settings to the reactive form + settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => { + // skips reactive event emissions to break a subscription cycle + this.settings.patchValue(settings as any, { emitEvent: false }); + }); + + // bind policy to the reactive form + forwarder$ + .pipe( + switchMap((forwarder) => { + const constraints$ = this.generatorService + .policy$(forwarder, { userId$: singleUserId$ }) + .pipe(map(({ constraints }) => [constraints, forwarder] as const)); + + return constraints$; + }), + takeUntil(this.destroyed$), + ) + .subscribe(([constraints, forwarder]) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (forwarder.request.includes(name as any)) { + control.enable({ emitEvent: false }); + control.setValidators( + // the configuration's type erasure affects `toValidators` as well + toValidators(name, forwarder, constraints), + ); + } else { + control.disable({ emitEvent: false }); + control.clearValidators(); + } + } + }); + + // the first emission is the current value; subsequent emissions are updates + settings$$ + .pipe( + map((settings$) => settings$.pipe(skip(1))), + switchAll(), + takeUntil(this.destroyed$), + ) + .subscribe(this.onUpdated); + + // now that outputs are set up, connect inputs + this.settings.valueChanges + .pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) + .subscribe(([value, settings]) => { + settings.next(value); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.refresh$.complete(); + if ("forwarder" in changes) { + this.forwarderId$.next(this.forwarder); + } + } + + protected displayDomain: boolean; + protected displayToken: boolean; + protected displayBaseUrl: boolean; + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly refresh$ = new Subject(); + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 96622774a3f..58117bec495 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -5,8 +5,11 @@ 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 { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, @@ -30,6 +33,7 @@ import { import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordSettingsComponent } from "./password-settings.component"; @@ -67,18 +71,27 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ provide: CredentialGeneratorService, useClass: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], declarations: [ CatchallSettingsComponent, CredentialGeneratorComponent, + ForwarderSettingsComponent, SubaddressSettingsComponent, - UsernameSettingsComponent, PasswordGeneratorComponent, - PasswordSettingsComponent, PassphraseSettingsComponent, + PasswordSettingsComponent, UsernameGeneratorComponent, + UsernameSettingsComponent, ], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 37c40ce8b1b..f6ec1b17e2d 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -21,9 +21,9 @@ import { Generators, PasswordAlgorithm, GeneratedCredential, - CredentialGeneratorInfo, CredentialAlgorithm, isPasswordAlgorithm, + AlgorithmInfo, } from "@bitwarden/generator-core"; /** Options group for passwords */ @@ -52,36 +52,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { /** tracks the currently selected credential type */ protected credentialType$ = new BehaviorSubject(null); - /** - * Emits the copy button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("copyPassword"); - } - - return this.i18nService.t("copyPassphrase"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("generatePassword"); - } - - return this.i18nService.t("generatePassphrase"); - }), - ); - /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -208,12 +178,28 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { protected passwordOptions$ = new BehaviorSubject[]>([]); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); - private toOptions(algorithms: CredentialGeneratorInfo[]) { + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index ad8cd796123..3d175f32f78 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -3,45 +3,54 @@
- -
-
{{ "options" | i18n }}
+

{{ "options" | i18n }}

-
+ {{ "type" | i18n }} - + {{ credentialTypeHint$ | async }}
+
+ + {{ "service" | i18n }} + + +
+ this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.typeOptions$); + .subscribe(([usernames, forwarders]) => { + this.typeOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -103,7 +137,22 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -115,20 +164,96 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { }); }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + + this.username.valueChanges + .pipe( + map( + (username): CascadeValue => + username.nav === FORWARDER + ? { nav: username.nav } + : { nav: username.nav, algorithm: JSON.parse(username.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeIdentifier$); + + this.forwarder.valueChanges + .pipe( + map( + (forwarder): CascadeValue => + forwarder.nav === NONE_SELECTED + ? { nav: forwarder.nav } + : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const showForwarder = !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const selection = username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); + } else { + return null; + } + }), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); - this.credential.valueChanges + this.algorithm$ .pipe( - filter(({ type }) => !!type), + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ type }, preference]) => { - if (isEmailAlgorithm(type)) { - preference.email.algorithm = type; + .subscribe(([algorithm, preference]) => { + if (isEmailAlgorithm(algorithm.id)) { + preference.email.algorithm = algorithm.id; preference.email.updated = new Date(); - } else if (isUsernameAlgorithm(type)) { - preference.username.algorithm = type; + } else if (isUsernameAlgorithm(algorithm.id)) { + preference.username.algorithm = algorithm.id; preference.username.updated = new Date(); } else { return; @@ -137,31 +262,61 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { preferences.next(preference); }); - // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => { - // this generator supports email & username; the last preference - // set by the user "wins" - const preference = email.updated > username.updated ? email.algorithm : username.algorithm; - - // break subscription loop - this.credential.setValue({ type: preference }, { emitEvent: false }); - - const algorithm = this.generatorService.algorithm(preference); - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + + // construct cascade metadata + const cascade = { + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ username, forwarder }) => { + // update navigation; break subscription loop + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -179,17 +334,52 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { case "username": return this.generatorService.generate$(Generators.username, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + return this.generatorService.generate$(configuration, dependencies); + } + + throw new Error(`Invalid generator type: "${type}"`); } /** Lists the credential types supported by the component. */ - protected typeOptions$ = new BehaviorSubject[]>([]); + protected typeOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + + /** Lists the credential types supported by the component. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks forwarder control visibility */ + protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -203,10 +393,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 2049a285e25..d6cd4e6fbaf 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -63,7 +63,7 @@ function getConstraint( ) { if (policy && key in policy) { return policy[key] ?? config[key]; - } else if (key in config) { + } else if (config && key in config) { return config[key]; } } diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts index 6c351b82e33..e54ec34e497 100644 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ b/libs/tools/generator/core/src/data/generator-types.ts @@ -5,7 +5,7 @@ export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as co export const UsernameAlgorithms = Object.freeze(["username"] as const); /** Types of email addresses that may be generated by the credential generator */ -export const EmailAlgorithms = Object.freeze(["catchall", "forwarder", "subaddress"] as const); +export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const); /** All types of credentials that may be generated by the credential generator */ export const CredentialAlgorithms = Object.freeze([ diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 2c96b0c2d39..d86eb52a8fa 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,9 +1,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; -import { Randomizer } from "../abstractions"; -import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine"; +import { + EmailRandomizer, + ForwarderConfiguration, + PasswordRandomizer, + UsernameRandomizer, +} from "../engine"; +import { Forwarder } from "../engine/forwarder"; import { DefaultPolicyEvaluator, DynamicPasswordPolicyConstraints, @@ -25,6 +31,7 @@ import { CredentialGenerator, CredentialGeneratorConfiguration, EffUsernameGenerationOptions, + GeneratorDependencyProvider, NoPolicy, PassphraseGenerationOptions, PassphraseGeneratorPolicy, @@ -45,10 +52,15 @@ const PASSPHRASE = Object.freeze({ id: "passphrase", category: "password", nameKey: "passphrase", + generateKey: "generatePassphrase", + copyKey: "copyPassphrase", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -82,10 +94,15 @@ const PASSWORD = Object.freeze({ id: "password", category: "password", nameKey: "password", + generateKey: "generatePassword", + copyKey: "copyPassword", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -127,10 +144,15 @@ const USERNAME = Object.freeze({ id: "username", category: "username", nameKey: "randomWord", + generateKey: "generateUsername", + copyKey: "copyUsername", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new UsernameRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new UsernameRandomizer(dependencies.randomizer); }, }, settings: { @@ -158,10 +180,15 @@ const CATCHALL = Object.freeze({ category: "email", nameKey: "catchallEmail", descriptionKey: "catchallEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -189,10 +216,15 @@ const SUBADDRESS = Object.freeze({ category: "email", nameKey: "plusAddressedEmail", descriptionKey: "plusAddressedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({ }, } satisfies CredentialGeneratorConfiguration); +export function toCredentialGeneratorConfiguration( + configuration: ForwarderConfiguration, +) { + const forwarder = Object.freeze({ + id: { forwarder: configuration.id }, + category: "email", + nameKey: configuration.name, + descriptionKey: "forwardedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", + onlyOnRequest: true, + request: configuration.forwarder.request, + engine: { + create(dependencies: GeneratorDependencyProvider) { + // FIXME: figure out why `configuration` fails to typecheck + const config: any = configuration; + return new Forwarder(config, dependencies.client, dependencies.i18nService); + }, + }, + settings: { + initial: configuration.forwarder.defaultSettings, + constraints: configuration.forwarder.settingsConstraints, + account: configuration.forwarder.settings, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy) { + return new IdentityConstraint(); + }, + }, + } satisfies CredentialGeneratorConfiguration); + + return forwarder; +} + /** Generator configurations */ export const Generators = Object.freeze({ /** Passphrase generator configuration */ diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 6132891b368..71c80fc9dbe 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,3 +1,7 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; + +import { ForwarderConfiguration } from "../engine"; import { AddyIo } from "../integration/addy-io"; import { DuckDuckGo } from "../integration/duck-duck-go"; import { Fastmail } from "../integration/fastmail"; @@ -5,6 +9,13 @@ import { FirefoxRelay } from "../integration/firefox-relay"; import { ForwardEmail } from "../integration/forward-email"; import { SimpleLogin } from "../integration/simple-login"; +/** Fixed list of integrations available to the application + * @example + * + * // Use `toCredentialGeneratorConfiguration(id :ForwarderIntegration)` + * // to convert an integration to a generator configuration + * const generator = toCredentialGeneratorConfiguration(Integrations.AddyIo); + */ export const Integrations = Object.freeze({ AddyIo, DuckDuckGo, @@ -13,3 +24,15 @@ export const Integrations = Object.freeze({ ForwardEmail, SimpleLogin, } as const); + +const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); + +export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { + const maybeForwarder = integrations.get(id); + + if (maybeForwarder && "forwarder" in maybeForwarder) { + return maybeForwarder as ForwarderConfiguration; + } else { + return null; + } +} diff --git a/libs/tools/generator/core/src/engine/forwarder-configuration.ts b/libs/tools/generator/core/src/engine/forwarder-configuration.ts index 95c9add140a..7813f457399 100644 --- a/libs/tools/generator/core/src/engine/forwarder-configuration.ts +++ b/libs/tools/generator/core/src/engine/forwarder-configuration.ts @@ -1,11 +1,14 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration"; -import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { ApiSettings, SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request"; import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; +import { Constraints } from "@bitwarden/common/tools/types"; import { ForwarderContext } from "./forwarder-context"; +import { EmailDomainSettings, EmailPrefixSettings } from "./settings"; /** Mixin for transmitting `getAccountId` result. */ export type AccountRequest = { @@ -24,8 +27,16 @@ export type GetAccountIdRpcDef< Request extends IntegrationRequest = IntegrationRequest, > = RpcConfiguration, string>; +export type ForwarderRequestFields = keyof (ApiSettings & + SelfHostedApiSettings & + EmailDomainSettings & + EmailPrefixSettings); + /** Forwarder-specific static definition */ export type ForwarderConfiguration< + // FIXME: simply forwarder settings to an object that has all + // settings properties. The runtime dynamism should be limited + // to which have values, not which have properties listed. Settings extends ApiSettings, Request extends IntegrationRequest = IntegrationRequest, > = IntegrationConfiguration & { @@ -34,12 +45,30 @@ export type ForwarderConfiguration< /** default value of all fields */ defaultSettings: Partial; - /** forwarder settings storage */ + settingsConstraints: Constraints; + + /** Well-known fields to display on the forwarder screen */ + request: readonly ForwarderRequestFields[]; + + /** forwarder settings storage + * @deprecated use local.settings instead + */ settings: UserKeyDefinition; - /** forwarder settings import buffer; `undefined` when there is no buffer. */ + /** forwarder settings import buffer; `undefined` when there is no buffer. + * @deprecated use local.settings import + */ importBuffer?: BufferedKeyDefinition; + /** locally stored data; forwarder-partitioned */ + local: { + /** integration settings storage */ + settings: ObjectKey; + + /** plaintext import buffer - used during data migrations */ + import?: ObjectKey, Settings>; + }; + /** createForwardingEmail RPC definition */ createForwardingEmail: CreateForwardingEmailRpcDef; diff --git a/libs/tools/generator/core/src/engine/forwarder.ts b/libs/tools/generator/core/src/engine/forwarder.ts new file mode 100644 index 00000000000..523c6fdf1ec --- /dev/null +++ b/libs/tools/generator/core/src/engine/forwarder.ts @@ -0,0 +1,75 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ApiSettings, + IntegrationRequest, + RestClient, +} from "@bitwarden/common/tools/integration/rpc"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { CredentialGenerator, GeneratedCredential } from "../types"; + +import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration"; +import { ForwarderContext } from "./forwarder-context"; +import { CreateForwardingAddressRpc, GetAccountIdRpc } from "./rpc"; + +/** Generation algorithms that query an email forwarding service to + * create anonymized email addresses. + */ +export class Forwarder implements CredentialGenerator { + /** Instantiates the email forwarder engine + * @param configuration The forwarder to query + * @param client requests data from the forwarding service + * @param i18nService localizes messages sent to the forwarding service + * and user-addressable errors + */ + constructor( + private configuration: ForwarderConfiguration, + private client: RestClient, + private i18nService: I18nService, + ) {} + + async generate(request: GenerationRequest, settings: ApiSettings) { + const requestOptions: IntegrationRequest & AccountRequest = { website: request.website }; + + const getAccount = await this.getAccountId(this.configuration, settings); + if (getAccount) { + requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions); + } + + const create = this.createForwardingAddress(this.configuration, settings); + const result = await this.client.fetchJson(create, requestOptions); + const id = { forwarder: this.configuration.id }; + + return new GeneratedCredential(result, id, Date.now()); + } + + private createContext( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + return new ForwarderContext(configuration, settings, this.i18nService); + } + + private createForwardingAddress( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + const context = this.createContext(configuration, settings); + const rpc = new CreateForwardingAddressRpc(configuration, context); + return rpc; + } + + private getAccountId( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + if (!configuration.forwarder.getAccountId) { + return null; + } + + const context = this.createContext(configuration, settings); + const rpc = new GetAccountIdRpc(configuration, context); + + return rpc; + } +} diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 8f594827e95..2d265ca9bfc 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -44,6 +51,40 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + baseUrl: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.AddyIo.local.settings", + key: "addyIoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.AddyIo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, AddyIoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "addyIoForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +93,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); export const AddyIo = Object.freeze({ diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index 0c13ac6b632..4c1d672cc60 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -36,6 +43,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.DuckDuckGo.local.settings", + key: "duckDuckGoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.DuckDuckGo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, DuckDuckGoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "duckDuckGoForwarder", { deserializer: (value) => value, clearOn: [], @@ -44,7 +83,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 0987540e036..13aa8db6247 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, @@ -101,6 +108,41 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + getAccountId, + request: ["token"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + prefix: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Fastmail.local.settings" + key: "fastmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Fastmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FastmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "fastmailForwarder", { deserializer: (value) => value, clearOn: [], @@ -109,8 +151,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, - getAccountId, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index 4feb8a0bd99..9c965a4c9cd 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -40,6 +47,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Firefox.local.settings", + key: "firefoxRelayForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Firefox.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FirefoxRelaySettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "firefoxRelayForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +91,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index c4ef21d9d30..a128159fcd6 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -43,6 +50,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + request: ["token", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.ForwardEmail.local.settings", + key: "forwardEmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.ForwardEmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, ForwardEmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "forwardEmailForwarder", { deserializer: (value) => value, clearOn: [], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 88730d0578e..d4b297fc37e 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -45,6 +52,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.SimpleLogin.local.settings", + key: "simpleLoginForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.SimpleLogin.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, SimpleLoginSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "simpleLoginForwarder", { deserializer: (value) => value, clearOn: [], @@ -57,7 +96,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/rx.spec.ts b/libs/tools/generator/core/src/rx.spec.ts deleted file mode 100644 index b98e79bb074..00000000000 --- a/libs/tools/generator/core/src/rx.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { EmptyError, Subject, tap } from "rxjs"; - -import { anyComplete, on, ready } from "./rx"; - -describe("anyComplete", () => { - it("emits true when its input completes", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("completes when its input is already complete", () => { - const input = new Subject(); - input.complete(); - - let completed = false; - anyComplete(input).subscribe({ complete: () => (completed = true) }); - - expect(completed).toBe(true); - }); - - it("completes when any input completes", () => { - const input$ = new Subject(); - const completing$ = new Subject(); - - let completed = false; - anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); - completing$.complete(); - - expect(completed).toBe(true); - }); - - it("ignores emissions", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.next(1); - input$.next(2); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("forwards errors", () => { - const input$ = new Subject(); - const expected = { some: "error" }; - - let error = null; - anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); - input$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("ready", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(ready(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("suppresses source emissions until all watches emit", () => { - const watchA$ = new Subject(); - const watchB$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready([watchA$, watchB$])); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // preconditions: no emissions - source$.next(1); - expect(results).toEqual([]); - watchA$.next(); - expect(results).toEqual([]); - - watchB$.next(); - - expect(results).toEqual([1]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next(); - - expect(results).toEqual([2]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - source$.next(2); - - expect(results).toEqual([1, 2]); - }); - - it("ignores repeated watch emissions", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("on", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: on$ should be cold - const on$ = source$.pipe(on(watch$)); - expect(connected).toBeFalsy(); - - on$.subscribe(); - - expect(connected).toBeTruthy(); - }); - - it("suppresses source emissions until `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - // precondition: on$ should be cold - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("repeats source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - watch$.next(); - - expect(results).toEqual([1, 1]); - }); - - it("updates source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("emits a value when `on` emits before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("ignores repeated `on` emissions before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("emits only the latest source emission when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - - source$.next(2); - source$.next(3); - watch$.next(); - - expect(results).toEqual([1, 3]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("completes when its watch completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - watch$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); -}); diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 851b6cfe7c7..070d34d37d8 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -1,18 +1,4 @@ -import { - concat, - concatMap, - connect, - endWith, - first, - ignoreElements, - map, - Observable, - pipe, - race, - ReplaySubject, - takeUntil, - zip, -} from "rxjs"; +import { map, pipe } from "rxjs"; import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; @@ -51,86 +37,3 @@ export function newDefaultEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); }; } - -/** Create an observable that, once subscribed, emits `true` then completes when - * any input completes. If an input is already complete when the subscription - * occurs, it emits immediately. - * @param watch$ the observable(s) to watch for completion; if an array is passed, - * null and undefined members are ignored. If `watch$` is empty, `anyComplete` - * will never complete. - * @returns An observable that emits `true` when any of its inputs - * complete. The observable forwards the first error from its input. - * @remarks This method is particularly useful in combination with `takeUntil` and - * streams that are not guaranteed to complete on their own. - */ -export function anyComplete(watch$: Observable | Observable[]): Observable { - if (Array.isArray(watch$)) { - const completes$ = watch$ - .filter((w$) => !!w$) - .map((w$) => w$.pipe(ignoreElements(), endWith(true))); - const completed$ = race(completes$); - return completed$; - } else { - return watch$.pipe(ignoreElements(), endWith(true)); - } -} - -/** - * Create an observable that delays the input stream until all watches have - * emitted a value. The watched values are not included in the source stream. - * The last emission from the source is output when all the watches have - * emitted at least once. - * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, - * `ready` will never emit. - * @returns An observable that emits when the source stream emits. The observable - * errors if one of its watches completes before emitting. It also errors if one - * of its watches errors. - */ -export function ready(watch$: Observable | Observable[]) { - const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; - return pipe( - connect>((source$) => { - // this subscription is safe because `source$` connects only after there - // is an external subscriber. - const source = new ReplaySubject(1); - source$.subscribe(source); - - // `concat` is subscribed immediately after it's returned, at which point - // `zip` blocks until all items in `watching$` are ready. If that occurs - // after `source$` is hot, then the replay subject sends the last-captured - // emission through immediately. Otherwise, `ready` waits for the next - // emission - return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe( - takeUntil(anyComplete(source)), - ); - }), - ); -} - -/** - * Create an observable that emits the latest value of the source stream - * when `watch$` emits. If `watch$` emits before the stream emits, then - * an emission occurs as soon as a value becomes ready. - * @param watch$ the observable that triggers emissions - * @returns An observable that emits when `watch$` emits. The observable - * errors if its source stream errors. It also errors if `on` errors. It - * completes if its watch completes. - * - * @remarks This works like `audit`, but it repeats emissions when - * watch$ fires. - */ -export function on(watch$: Observable) { - return pipe( - connect>((source$) => { - const source = new ReplaySubject(1); - source$.subscribe(source); - - return watch$ - .pipe( - ready(source), - concatMap(() => source.pipe(first())), - ) - .pipe(takeUntil(anyComplete(source))); - }), - ); -} 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 88f1447e98d..e11e555d6aa 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,12 +1,17 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { StateConstraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; import { FakeStateProvider, @@ -67,15 +72,20 @@ const SomeTime = new Date(1); const SomeAlgorithm = "passphrase"; const SomeCategory = "password"; const SomeNameKey = "passphraseKey"; +const SomeGenerateKey = "generateKey"; +const SomeCopyKey = "copyKey"; // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { id: SomeAlgorithm, category: SomeCategory, nameKey: SomeNameKey, + generateKey: SomeGenerateKey, + copyKey: SomeCopyKey, onlyOnRequest: false, + request: [], engine: { - create: (randomizer) => { + create: (_randomizer) => { return { generate: (request, settings) => { const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; @@ -159,10 +169,22 @@ const stateProvider = new FakeStateProvider(accountService); // fake randomizer const randomizer = mock(); +const i18nService = mock(); + +const apiService = mock(); + +const encryptService = mock(); + +const cryptoService = mock(); + describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + i18nService.t.mockImplementation((key) => key); + apiService.fetch.mockImplementation(() => Promise.resolve(mock())); + const keyAvailable = new BehaviorSubject({} as UserKey); + cryptoService.userKey$.mockReturnValue(keyAvailable); jest.clearAllMocks(); }); @@ -170,7 +192,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const result = await generated.expectEmission(); @@ -183,7 +213,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await accountService.switchAccount(AnotherUser); @@ -200,7 +238,15 @@ describe("CredentialGeneratorService", () => { const someSettings = { foo: "some value" }; const anotherSettings = { foo: "another value" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); @@ -220,7 +266,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); @@ -233,7 +287,15 @@ describe("CredentialGeneratorService", () => { it("errors when `website$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let error = null; @@ -250,7 +312,15 @@ describe("CredentialGeneratorService", () => { it("completes when `website$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let completed = false; @@ -268,7 +338,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -280,7 +358,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.pipe(filter((u) => !!u)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -296,7 +382,15 @@ describe("CredentialGeneratorService", () => { it("errors when `user$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let error = null; @@ -313,7 +407,15 @@ describe("CredentialGeneratorService", () => { it("completes when `user$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let completed = false; @@ -331,7 +433,15 @@ describe("CredentialGeneratorService", () => { 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 generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); const results: any[] = []; @@ -365,7 +475,15 @@ describe("CredentialGeneratorService", () => { it("errors when `on$` errors", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let error: any = null; @@ -383,7 +501,15 @@ describe("CredentialGeneratorService", () => { it("completes when `on$` completes", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let complete = false; @@ -406,54 +532,86 @@ describe("CredentialGeneratorService", () => { describe("algorithms", () => { it("outputs password generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("password"); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.username); - expect(result).not.toContain(Generators.catchall); + expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); }); it("outputs username generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("username"); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.catchall); - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); it("outputs email generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("email"); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.username); - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); it("combines metadata across categories", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms(["username", "email"]); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current categories - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); }); @@ -461,39 +619,71 @@ describe("CredentialGeneratorService", () => { // these tests cannot use the observable tracker because they return // data that cannot be cloned it("returns password metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("password")); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); }); it("returns username metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("username")); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); }); it("returns email metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("email")); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); }); it("returns username and email metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["username", "email"])); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); }); // Subsequent tests focus on passwords and passphrases as an example of policy @@ -501,13 +691,21 @@ describe("CredentialGeneratorService", () => { it("enforces the active user's policy", async () => { const policy$ = new BehaviorSubject([passwordOverridePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["password"])); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the active user", async () => { @@ -518,7 +716,15 @@ describe("CredentialGeneratorService", () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); @@ -533,34 +739,50 @@ describe("CredentialGeneratorService", () => { PolicyType.PasswordGenerator, SomeUser, ); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenNthCalledWith( 2, PolicyType.PasswordGenerator, AnotherUser, ); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); + expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); }); it("reads an arbitrary user's settings", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the arbitrary user", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -572,17 +794,25 @@ describe("CredentialGeneratorService", () => { const [someResult, anotherResult] = results; expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); + expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); }); it("errors when the arbitrary user's stream errors", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -600,7 +830,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -618,7 +856,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -642,7 +888,15 @@ describe("CredentialGeneratorService", () => { 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(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -652,7 +906,15 @@ 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(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -664,7 +926,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -672,7 +942,7 @@ describe("CredentialGeneratorService", () => { }); it("follows changes to the active user", async () => { - // initialize local accound service and state provider because this test is sensitive + // initialize local account service and state provider because this test is sensitive // to some shared data in `FakeAccountService`. const accountService = new FakeAccountService(accounts); const stateProvider = new FakeStateProvider(accountService); @@ -681,7 +951,15 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -698,7 +976,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); @@ -711,7 +997,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -730,7 +1024,15 @@ describe("CredentialGeneratorService", () => { it("errors when the arbitrary user's stream errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -748,7 +1050,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -766,7 +1076,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -790,7 +1108,15 @@ describe("CredentialGeneratorService", () => { describe("settings", () => { it("writes to the user's state", async () => { const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); subject.next({ foo: "next value" }); @@ -803,7 +1129,15 @@ describe("CredentialGeneratorService", () => { it("waits for the user to become available", async () => { const singleUserId = new BehaviorSubject(null); const singleUserId$ = singleUserId.asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); let completed = false; const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { @@ -821,7 +1155,15 @@ describe("CredentialGeneratorService", () => { describe("policy$", () => { it("creates constraints without policy in effect when there is no policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); @@ -830,7 +1172,15 @@ describe("CredentialGeneratorService", () => { }); it("creates constraints with policy in effect when there is a policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); @@ -841,7 +1191,15 @@ describe("CredentialGeneratorService", () => { }); it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); @@ -862,7 +1220,15 @@ describe("CredentialGeneratorService", () => { }); it("follows user emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); @@ -884,7 +1250,15 @@ describe("CredentialGeneratorService", () => { }); it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const expectedError = { some: "error" }; @@ -902,7 +1276,15 @@ describe("CredentialGeneratorService", () => { }); it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); 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 693ffd654dc..a137c153a64 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -11,38 +11,60 @@ import { ignoreElements, map, Observable, - race, share, skipUntil, switchMap, takeUntil, + takeWhile, withLatestFrom, } from "rxjs"; import { Simplify } from "type-fest"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OnDependency, SingleUserDependency, + UserBound, UserDependency, } from "@bitwarden/common/tools/dependencies"; -import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency"; +import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { anyComplete } from "@bitwarden/common/tools/rx"; +import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; +import { UserEncryptor } from "@bitwarden/common/tools/state/user-encryptor.abstraction"; +import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserId } from "@bitwarden/common/types/guid"; import { Randomizer } from "../abstractions"; -import { Generators } from "../data"; +import { + Generators, + getForwarderConfiguration, + Integrations, + toCredentialGeneratorConfiguration, +} from "../data"; import { availableAlgorithms } from "../policies/available-algorithms-policy"; import { mapPolicyToConstraints } from "../rx"; import { CredentialAlgorithm, CredentialCategories, CredentialCategory, - CredentialGeneratorInfo, + AlgorithmInfo, CredentialPreference, + isForwarderIntegration, + ForwarderIntegration, } from "../types"; -import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; +import { + CredentialGeneratorConfiguration as Configuration, + CredentialGeneratorInfo, + GeneratorDependencyProvider, +} from "../types/credential-generator-configuration"; import { GeneratorConstraints } from "../types/generator-constraints"; import { PREFERENCES } from "./credential-preferences"; @@ -59,17 +81,33 @@ type Generate$Dependencies = Simplify & Partial; + + integration$?: Observable; }; type Algorithms$Dependencies = Partial; +const OPTIONS_FRAME_SIZE = 512; + export class CredentialGeneratorService { constructor( - private randomizer: Randomizer, - private stateProvider: StateProvider, - private policyService: PolicyService, + private readonly randomizer: Randomizer, + private readonly stateProvider: StateProvider, + private readonly policyService: PolicyService, + private readonly apiService: ApiService, + private readonly i18nService: I18nService, + private readonly encryptService: EncryptService, + private readonly cryptoService: CryptoService, ) {} + private getDependencyProvider(): GeneratorDependencyProvider { + return { + client: new RestClient(this.apiService, this.i18nService), + i18nService: this.i18nService, + randomizer: this.randomizer, + }; + } + // FIXME: the rxjs methods of this service can be a lot more resilient if // `Subjects` are introduced where sharing occurs @@ -84,18 +122,13 @@ export class CredentialGeneratorService { dependencies?: Generate$Dependencies, ) { // instantiate the engine - const engine = configuration.engine.create(this.randomizer); + const engine = configuration.engine.create(this.getDependencyProvider()); // 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$); - // if on$ triggers before settings are loaded, trigger as soon // as they become available. let readyOn$: Observable = null; @@ -116,7 +149,7 @@ export class CredentialGeneratorService { const generate$ = (readyOn$ ?? settings$).pipe( withLatestFrom(request$, settings$), concatMap(([, request, settings]) => engine.generate(request, settings)), - takeUntil(complete$), + takeUntil(anyComplete([request$, settings$])), ); return generate$; @@ -132,11 +165,11 @@ export class CredentialGeneratorService { algorithms$( category: CredentialCategory, dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory[], dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory | CredentialCategory[], dependencies?: Algorithms$Dependencies, @@ -163,7 +196,9 @@ export class CredentialGeneratorService { return policies$; }), map((available) => { - const filtered = algorithms.filter((c) => available.has(c.id)); + const filtered = algorithms.filter( + (c) => isForwarderIntegration(c.id) || available.has(c.id), + ); return filtered; }), ); @@ -175,24 +210,79 @@ export class CredentialGeneratorService { * @param category the category or categories of interest * @returns A list containing the requested metadata. */ - algorithms(category: CredentialCategory): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] { - const categories = Array.isArray(category) ? category : [category]; + algorithms(category: CredentialCategory): AlgorithmInfo[]; + algorithms(category: CredentialCategory[]): AlgorithmInfo[]; + algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] { + const categories: CredentialCategory[] = Array.isArray(category) ? category : [category]; + const algorithms = categories - .flatMap((c) => CredentialCategories[c]) - .map((c) => (c === "forwarder" ? null : Generators[c])) + .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[]) + .map((id) => this.algorithm(id)) .filter((info) => info !== null); - return algorithms; + const forwarders = Object.keys(Integrations) + .map((key: keyof typeof Integrations) => { + const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id }; + return this.algorithm(forwarder); + }) + .filter((forwarder) => categories.includes(forwarder.category)); + + return algorithms.concat(forwarders); } /** Look up the metadata for a specific generator algorithm * @param id identifies the algorithm * @returns the requested metadata, or `null` if the metadata wasn't found. */ - algorithm(id: CredentialAlgorithm): CredentialGeneratorInfo { - return (id === "forwarder" ? null : Generators[id]) ?? null; + algorithm(id: CredentialAlgorithm): AlgorithmInfo { + let generator: CredentialGeneratorInfo = null; + let integration: IntegrationMetadata = null; + + if (isForwarderIntegration(id)) { + const forwarderConfig = getForwarderConfiguration(id.forwarder); + integration = forwarderConfig; + + if (forwarderConfig) { + generator = toCredentialGeneratorConfiguration(forwarderConfig); + } + } else { + generator = Generators[id]; + } + + if (!generator) { + throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`); + } + + const info: AlgorithmInfo = { + id: generator.id, + category: generator.category, + name: integration ? integration.name : this.i18nService.t(generator.nameKey), + generate: this.i18nService.t(generator.generateKey), + copy: this.i18nService.t(generator.copyKey), + onlyOnRequest: generator.onlyOnRequest, + request: generator.request, + }; + + if (generator.descriptionKey) { + info.description = this.i18nService.t(generator.descriptionKey); + } + + return info; + } + + private encryptor$(userId: UserId) { + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor$ = this.cryptoService.userKey$(userId).pipe( + // complete when the account locks + takeWhile((key) => !!key), + map((key) => { + const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); + + return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; + }), + ); + + return encryptor$; } /** Get the settings for the provided configuration @@ -208,27 +298,21 @@ export class CredentialGeneratorService { dependencies?: Settings$Dependencies, ) { const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; - const completion$ = userId$.pipe(ignoreElements(), endWith(true)); + const constraints$ = this.policy$(configuration, { userId$ }); - const state$ = userId$.pipe( + const settings$ = userId$.pipe( filter((userId) => !!userId), distinctUntilChanged(), switchMap((userId) => { - const state$ = this.stateProvider - .getUserState$(configuration.settings.account, userId) - .pipe(takeUntil(completion$)); - + const state$ = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return state$; }), map((settings) => settings ?? structuredClone(configuration.settings.initial)), - ); - - const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe( - map(([settings, policy]) => { - const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy; - const adjusted = calibration.adjust(settings); - return adjusted; - }), + takeUntil(anyComplete(userId$)), ); return settings$; @@ -251,8 +335,11 @@ export class CredentialGeneratorService { ); // FIXME: enforce policy - const state = this.stateProvider.getUser(userId, PREFERENCES); - const subject = new UserStateSubject(state, { ...dependencies }); + const subject = new UserStateSubject( + PREFERENCES, + (key) => this.stateProvider.getUser(userId, key), + { singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } @@ -271,10 +358,14 @@ export class CredentialGeneratorService { const userId = await firstValueFrom( dependencies.singleUserId$.pipe(filter((userId) => !!userId)), ); - const state = this.stateProvider.getUser(userId, configuration.settings.account); + const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ }); - const subject = new UserStateSubject(state, { ...dependencies, constraints$ }); + const subject = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } 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 8302450d443..1798323ec63 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -1,4 +1,7 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Constraints } from "@bitwarden/common/tools/types"; import { Randomizer } from "../abstractions"; @@ -6,9 +9,58 @@ import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from ".. import { CredentialGenerator } from "./credential-generator"; +export type GeneratorDependencyProvider = { + randomizer: Randomizer; + client: RestClient; + i18nService: I18nService; +}; + +export type AlgorithmInfo = { + /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ + id: CredentialAlgorithm; + + /** The kind of credential generated by this configuration */ + category: CredentialCategory; + + /** Localized algorithm name */ + name: string; + + /* Localized generate button label */ + generate: string; + + /* Localized copy button label */ + copy: string; + + /** Localized algorithm description */ + description?: string; + + /** When true, credential generation must be explicitly requested. + * @remarks this property is useful when credential generation + * carries side effects, such as configuring a service external + * to Bitwarden. + */ + onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; +}; + /** Credential generator metadata common across credential generators */ export type CredentialGeneratorInfo = { /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; */ id: CredentialAlgorithm; @@ -21,15 +73,32 @@ export type CredentialGeneratorInfo = { /** Key used to localize the credential description in the I18nService */ descriptionKey?: string; + /* Localized generate button label */ + generateKey: string; + + /* Localized copy button label */ + copyKey: string; + /** When true, credential generation must be explicitly requested. * @remarks this property is useful when credential generation * carries side effects, such as configuring a service external * to Bitwarden. */ onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; }; -/** Credential generator metadata that relies upon typed setting and policy definitions. */ +/** Credential generator metadata that relies upon typed setting and policy definitions. + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ export type CredentialGeneratorConfiguration = CredentialGeneratorInfo & { /** An algorithm that generates credentials when ran. */ engine: { @@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration = CredentialGener // 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; + create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; }; /** Defines the stored parameters for credential generation */ settings: { @@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration = CredentialGener constraints: Constraints; /** storage location for account-global settings */ - account: UserKeyDefinition; + account: UserKeyDefinition | ObjectKey; + + /** storage location for *plaintext* settings imports */ + import?: UserKeyDefinition | ObjectKey, Settings>; }; /** defines how to construct policy for this settings instance */ diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 59727fb98f2..5b74d17fa4a 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,3 +1,5 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; + import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; /** A type of password that may be generated by the credential generator. */ @@ -9,8 +11,31 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; /** A type of email address that may be generated by the credential generator. */ export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; +export type ForwarderIntegration = { forwarder: IntegrationId }; + +/** Returns true when the input algorithm is a forwarder integration. */ +export function isForwarderIntegration( + algorithm: CredentialAlgorithm, +): algorithm is ForwarderIntegration { + return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; +} + +export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) { + if (lhs === rhs) { + return true; + } else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) { + return lhs.forwarder === rhs.forwarder; + } else { + return false; + } +} + /** A type of credential that may be generated by the credential generator. */ -export type CredentialAlgorithm = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; +export type CredentialAlgorithm = + | PasswordAlgorithm + | UsernameAlgorithm + | EmailAlgorithm + | ForwarderIntegration; /** Compound credential types supported by the credential generator. */ export const CredentialCategories = Object.freeze({ @@ -21,7 +46,7 @@ export const CredentialCategories = Object.freeze({ username: UsernameAlgorithms as Readonly, /** Lists algorithms in the "email" credential category */ - email: EmailAlgorithms as Readonly, + email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>, }); /** Returns true when the input algorithm is a password algorithm. */ @@ -40,7 +65,7 @@ export function isUsernameAlgorithm( /** Returns true when the input algorithm is an email algorithm. */ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { - return EmailAlgorithms.includes(algorithm as any); + return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm); } /** A type of compound credential that may be generated by the credential generator. */ diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 884d9760078..48272cbf602 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,4 +1,4 @@ -import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type"; +import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type"; export * from "./boundary"; export * from "./catchall-generator-options"; @@ -22,7 +22,7 @@ export * from "./word-options"; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. */ -export type GeneratorType = CredentialAlgorithm; +export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts index 99db65807ac..df10b563913 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form.module.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -2,8 +2,11 @@ import { NgModule } from "@angular/core"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { createRandomizer, @@ -32,7 +35,15 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ useClass: CredentialGeneratorService, provide: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], exports: [SendFormComponent], From 74dabb97bfd6902972b5af0d8156845c463d203a Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 23 Oct 2024 19:05:24 +0200 Subject: [PATCH 23/41] Move process reload ownership to key-management (#10853) --- .../browser/src/background/main.background.ts | 19 +++- .../src/background/runtime.background.ts | 6 +- apps/desktop/src/app/app.component.ts | 12 +- .../src/app/services/services.module.ts | 14 ++- .../abstractions/process-reload.service.ts | 6 + .../services/process-reload.service.ts | 106 ++++++++++++++++++ .../platform/abstractions/system.service.ts | 4 - .../src/platform/services/system.service.ts | 99 +--------------- 8 files changed, 147 insertions(+), 119 deletions(-) create mode 100644 libs/common/src/key-management/abstractions/process-reload.service.ts create mode 100644 libs/common/src/key-management/services/process-reload.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e5a4087510c..e31b40fe815 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -75,6 +75,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { ProcessReloadService } from "@bitwarden/common/key-management/services/process-reload.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -270,6 +272,7 @@ import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; import { NativeMessagingBackground } from "./nativeMessaging.background"; import RuntimeBackground from "./runtime.background"; + export default class MainBackground { messagingService: MessageSender; storageService: BrowserLocalStorageService; @@ -314,6 +317,7 @@ export default class MainBackground { badgeSettingsService: BadgeSettingsServiceAbstraction; domainSettingsService: DomainSettingsService; systemService: SystemServiceAbstraction; + processReloadService: ProcessReloadServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; policyService: InternalPolicyServiceAbstraction; @@ -408,7 +412,7 @@ export default class MainBackground { await this.refreshMenu(true); if (this.systemService != null) { await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); } }; @@ -1088,15 +1092,18 @@ export default class MainBackground { }; this.systemService = new SystemService( + this.platformUtilsService, + this.autofillSettingsService, + this.taskSchedulerService, + ); + + this.processReloadService = new ProcessReloadService( this.pinService, this.messagingService, - this.platformUtilsService, systemUtilsServiceReloadCallback, - this.autofillSettingsService, this.vaultTimeoutSettingsService, this.biometricStateService, this.accountService, - this.taskSchedulerService, ); // Other fields @@ -1122,7 +1129,7 @@ export default class MainBackground { this.platformUtilsService as BrowserPlatformUtilsService, this.notificationsService, this.autofillSettingsService, - this.systemService, + this.processReloadService, this.environmentService, this.messagingService, this.logService, @@ -1551,7 +1558,7 @@ export default class MainBackground { await this.mainContextMenuHandler?.noAccess(); await this.notificationsService.updateConnection(false); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); } private async needsStorageReseed(userId: UserId): Promise { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2bc2eadf261..f934c8544bd 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -6,10 +6,10 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AutofillOverlayVisibility, ExtensionCommand } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -40,7 +40,7 @@ export default class RuntimeBackground { private platformUtilsService: BrowserPlatformUtilsService, private notificationsService: NotificationsService, private autofillSettingsService: AutofillSettingsServiceAbstraction, - private systemService: SystemService, + private processReloadSerivce: ProcessReloadServiceAbstraction, private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, private logService: LogService, @@ -216,7 +216,7 @@ export default class RuntimeBackground { } await this.notificationsService.updateConnection(msg.command === "loggedIn"); - this.systemService.cancelProcessReload(); + this.processReloadSerivce.cancelProcessReload(); if (item) { await BrowserApi.focusWindow(item.commandToRetry.sender.tab.windowId); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index dceda128c85..83dc1619fa1 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -32,6 +32,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -142,6 +143,7 @@ export class AppComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private platformUtilsService: PlatformUtilsService, private systemService: SystemService, + private processReloadService: ProcessReloadServiceAbstraction, private stateService: StateService, private eventUploadService: EventUploadService, private policyService: InternalPolicyService, @@ -213,7 +215,7 @@ export class AppComponent implements OnInit, OnDestroy { // 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(); - this.systemService.cancelProcessReload(); + this.processReloadService.cancelProcessReload(); break; case "loggedOut": this.modalService.closeAll(); @@ -224,7 +226,7 @@ export class AppComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); break; case "authBlocked": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -268,15 +270,15 @@ export class AppComponent implements OnInit, OnDestroy { this.notificationsService.updateConnection(); await this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.systemService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(this.authService); break; case "startProcessReload": // 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.systemService.startProcessReload(this.authService); + this.processReloadService.startProcessReload(this.authService); break; case "cancelProcessReload": - this.systemService.cancelProcessReload(); + this.processReloadService.cancelProcessReload(); break; case "reloadProcess": ipc.platform.reloadProcess(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index c9b434aa964..36113684425 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -37,6 +37,8 @@ import { import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { ProcessReloadService } from "@bitwarden/common/key-management/services/process-reload.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService, @@ -196,16 +198,22 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SystemServiceAbstraction, useClass: SystemService, + deps: [ + PlatformUtilsServiceAbstraction, + AutofillSettingsServiceAbstraction, + TaskSchedulerService, + ], + }), + safeProvider({ + provide: ProcessReloadServiceAbstraction, + useClass: ProcessReloadService, deps: [ PinServiceAbstraction, MessagingServiceAbstraction, - PlatformUtilsServiceAbstraction, RELOAD_CALLBACK, - AutofillSettingsServiceAbstraction, VaultTimeoutSettingsService, BiometricStateService, AccountServiceAbstraction, - TaskSchedulerService, ], }), safeProvider({ diff --git a/libs/common/src/key-management/abstractions/process-reload.service.ts b/libs/common/src/key-management/abstractions/process-reload.service.ts new file mode 100644 index 00000000000..e46c1e23199 --- /dev/null +++ b/libs/common/src/key-management/abstractions/process-reload.service.ts @@ -0,0 +1,6 @@ +import { AuthService } from "../../auth/abstractions/auth.service"; + +export abstract class ProcessReloadServiceAbstraction { + abstract startProcessReload(authService: AuthService): Promise; + abstract cancelProcessReload(): void; +} diff --git a/libs/common/src/key-management/services/process-reload.service.ts b/libs/common/src/key-management/services/process-reload.service.ts new file mode 100644 index 00000000000..2f25d63b0fd --- /dev/null +++ b/libs/common/src/key-management/services/process-reload.service.ts @@ -0,0 +1,106 @@ +import { firstValueFrom, map, timeout } from "rxjs"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { BiometricStateService } from "@bitwarden/key-management"; + +import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { UserId } from "../../types/guid"; +import { ProcessReloadServiceAbstraction } from "../abstractions/process-reload.service"; + +export class ProcessReloadService implements ProcessReloadServiceAbstraction { + private reloadInterval: any = null; + + constructor( + private pinService: PinServiceAbstraction, + private messagingService: MessagingService, + private reloadCallback: () => Promise = null, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private biometricStateService: BiometricStateService, + private accountService: AccountService, + ) {} + + async startProcessReload(authService: AuthService): Promise { + const accounts = await firstValueFrom(this.accountService.accounts$); + if (accounts != null) { + const keys = Object.keys(accounts); + if (keys.length > 0) { + for (const userId of keys) { + let status = await firstValueFrom(authService.authStatusFor$(userId as UserId)); + status = await authService.getAuthStatus(userId); + if (status === AuthenticationStatus.Unlocked) { + return; + } + } + } + } + + // A reloadInterval has already been set and is executing + if (this.reloadInterval != null) { + return; + } + + // If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock. + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + if (userId != null) { + const ephemeralPin = await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId); + if (ephemeralPin != null) { + return; + } + } + + this.cancelProcessReload(); + await this.executeProcessReload(); + } + + private async executeProcessReload() { + const biometricLockedFingerprintValidated = await firstValueFrom( + this.biometricStateService.fingerprintValidated$, + ); + if (!biometricLockedFingerprintValidated) { + clearInterval(this.reloadInterval); + this.reloadInterval = null; + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe( + map((a) => a?.id), + timeout(500), + ), + ); + // Replace current active user if they will be logged out on reload + if (activeUserId != null) { + const timeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService + .getVaultTimeoutActionByUserId$(activeUserId) + .pipe(timeout(500)), // safety feature to avoid this call hanging and stopping process reload from clearing memory + ); + if (timeoutAction === VaultTimeoutAction.LogOut) { + const nextUser = await firstValueFrom( + this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)), + ); + await this.accountService.switchAccount(nextUser); + } + } + + this.messagingService.send("reloadProcess"); + if (this.reloadCallback != null) { + await this.reloadCallback(); + } + return; + } + if (this.reloadInterval == null) { + this.reloadInterval = setInterval(async () => await this.executeProcessReload(), 1000); + } + } + + cancelProcessReload(): void { + if (this.reloadInterval != null) { + clearInterval(this.reloadInterval); + this.reloadInterval = null; + } + } +} diff --git a/libs/common/src/platform/abstractions/system.service.ts b/libs/common/src/platform/abstractions/system.service.ts index 204e336fbf4..7a34a313528 100644 --- a/libs/common/src/platform/abstractions/system.service.ts +++ b/libs/common/src/platform/abstractions/system.service.ts @@ -1,8 +1,4 @@ -import { AuthService } from "../../auth/abstractions/auth.service"; - export abstract class SystemService { - abstract startProcessReload(authService: AuthService): Promise; - abstract cancelProcessReload(): void; abstract clearClipboard(clipboardValue: string, timeoutMs?: number): Promise; abstract clearPendingClipboard(): Promise; } diff --git a/libs/common/src/platform/services/system.service.ts b/libs/common/src/platform/services/system.service.ts index 357737391c2..03e96af75b5 100644 --- a/libs/common/src/platform/services/system.service.ts +++ b/libs/common/src/platform/services/system.service.ts @@ -1,16 +1,6 @@ -import { firstValueFrom, map, Subscription, timeout } from "rxjs"; +import { firstValueFrom, Subscription } from "rxjs"; -import { BiometricStateService } from "@bitwarden/key-management"; - -import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; -import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "../../auth/abstractions/account.service"; -import { AuthService } from "../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; -import { UserId } from "../../types/guid"; -import { MessagingService } from "../abstractions/messaging.service"; import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service"; import { Utils } from "../misc/utils"; @@ -18,19 +8,12 @@ import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum"; import { TaskSchedulerService } from "../scheduling/task-scheduler.service"; export class SystemService implements SystemServiceAbstraction { - private reloadInterval: any = null; private clearClipboardTimeoutSubscription: Subscription; private clearClipboardTimeoutFunction: () => Promise = null; constructor( - private pinService: PinServiceAbstraction, - private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, - private reloadCallback: () => Promise = null, private autofillSettingsService: AutofillSettingsServiceAbstraction, - private vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private biometricStateService: BiometricStateService, - private accountService: AccountService, private taskSchedulerService: TaskSchedulerService, ) { this.taskSchedulerService.registerTaskHandler( @@ -39,86 +22,6 @@ export class SystemService implements SystemServiceAbstraction { ); } - async startProcessReload(authService: AuthService): Promise { - const accounts = await firstValueFrom(this.accountService.accounts$); - if (accounts != null) { - const keys = Object.keys(accounts); - if (keys.length > 0) { - for (const userId of keys) { - let status = await firstValueFrom(authService.authStatusFor$(userId as UserId)); - status = await authService.getAuthStatus(userId); - if (status === AuthenticationStatus.Unlocked) { - return; - } - } - } - } - - // A reloadInterval has already been set and is executing - if (this.reloadInterval != null) { - return; - } - - // If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - if (userId != null) { - const ephemeralPin = await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId); - if (ephemeralPin != null) { - return; - } - } - - this.cancelProcessReload(); - await this.executeProcessReload(); - } - - private async executeProcessReload() { - const biometricLockedFingerprintValidated = await firstValueFrom( - this.biometricStateService.fingerprintValidated$, - ); - if (!biometricLockedFingerprintValidated) { - clearInterval(this.reloadInterval); - this.reloadInterval = null; - - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe( - map((a) => a?.id), - timeout(500), - ), - ); - // Replace current active user if they will be logged out on reload - if (activeUserId != null) { - const timeoutAction = await firstValueFrom( - this.vaultTimeoutSettingsService - .getVaultTimeoutActionByUserId$(activeUserId) - .pipe(timeout(500)), // safety feature to avoid this call hanging and stopping process reload from clearing memory - ); - if (timeoutAction === VaultTimeoutAction.LogOut) { - const nextUser = await firstValueFrom( - this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)), - ); - await this.accountService.switchAccount(nextUser); - } - } - - this.messagingService.send("reloadProcess"); - if (this.reloadCallback != null) { - await this.reloadCallback(); - } - return; - } - if (this.reloadInterval == null) { - this.reloadInterval = setInterval(async () => await this.executeProcessReload(), 1000); - } - } - - cancelProcessReload(): void { - if (this.reloadInterval != null) { - clearInterval(this.reloadInterval); - this.reloadInterval = null; - } - } - async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise { this.clearClipboardTimeoutSubscription?.unsubscribe(); From 7b8aac229c8aabb31fd77a2ab9a65f9241d6cd2f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:30:25 -0700 Subject: [PATCH 24/41] [PM-13456] - Password health service (#11658) * add password health service * add spec. fix logic in password reuse * move service to bitwarden_license * revert change to tsconfig * fix spec * fix import --- .../password-health-members.component.ts | 177 +++--------------- .../password-health.component.spec.ts | 78 ++------ .../password-health.component.ts | 172 ++--------------- .../password-health.mock.ts | 66 ------- apps/web/tsconfig.json | 1 + bitwarden_license/bit-common/jest.config.js | 6 +- .../reports/access-intelligence/index.ts | 1 + .../services/ciphers.mock.ts | 128 +++++++++++++ .../access-intelligence/services/index.ts | 2 + .../member-cipher-details-response.mock.ts | 68 +++++++ .../services/password-health.service.spec.ts | 136 ++++++++++++++ .../services/password-health.service.ts | 166 ++++++++++++++++ bitwarden_license/bit-common/test.setup.ts | 1 + 13 files changed, 560 insertions(+), 442 deletions(-) delete mode 100644 apps/web/src/app/tools/access-intelligence/password-health.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts create mode 100644 bitwarden_license/bit-common/test.setup.ts diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts index fd04974b2ce..30c9ad8dba8 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeModule, @@ -28,10 +26,6 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; // eslint-disable-next-line no-restricted-imports import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; - -import { userData } from "./password-health.mock"; @Component({ standalone: true, @@ -47,24 +41,18 @@ import { userData } from "./password-health.mock"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthMembersComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); - dataSource = new TableDataSource(); - totalMembersMap = new Map(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; + dataSource = new TableDataSource(); loading = true; @@ -73,7 +61,6 @@ export class PasswordHealthMembersComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -83,151 +70,29 @@ export class PasswordHealthMembersComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); - - // mock data - will be replaced with actual data - userData.forEach((user) => { - user.cipherIds.forEach((cipherId: string) => { - if (this.totalMembersMap.has(cipherId)) { - this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); - } else { - this.totalMembersMap.set(cipherId, 1); - } - }); - }); } - async setCiphers() { - // const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - const allCiphers = cipherData; - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) || 0 + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } - const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private isUserNameNotEmpty(c: CipherView): boolean { - return !Utils.isNullOrWhitespace(c.login.username); - } - - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.totalMembersMap = passwordHealthService.totalMembersMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts index 4a6d5c50ee1..d41807e7d2d 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts @@ -1,11 +1,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, convertToParamMap } from "@angular/router"; -import { MockProxy, mock } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -14,39 +14,30 @@ import { TableBodyDirective } from "@bitwarden/components/src/table/table.compon import { LooseComponentsModule } from "../../shared"; import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; import { PasswordHealthComponent } from "./password-health.component"; describe("PasswordHealthComponent", () => { let component: PasswordHealthComponent; let fixture: ComponentFixture; - let passwordStrengthService: MockProxy; - let organizationService: MockProxy; - let cipherServiceMock: MockProxy; - let auditServiceMock: MockProxy; const activeRouteParams = convertToParamMap({ organizationId: "orgId" }); beforeEach(async () => { - passwordStrengthService = mock(); - auditServiceMock = mock(); - organizationService = mock({ - get: jest.fn().mockResolvedValue({ id: "orgId" } as Organization), - }); - cipherServiceMock = mock({ - getAllFromApiForOrganization: jest.fn().mockResolvedValue(cipherData), - }); - await TestBed.configureTestingModule({ imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule], declarations: [TableBodyDirective], providers: [ - { provide: CipherService, useValue: cipherServiceMock }, - { provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService }, - { provide: OrganizationService, useValue: organizationService }, + { provide: CipherService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: AuditService, useValue: auditServiceMock }, + { provide: AuditService, useValue: mock() }, + { + provide: PasswordStrengthServiceAbstraction, + useValue: mock(), + }, + { + provide: PasswordHealthService, + useValue: mock(), + }, { provide: ActivatedRoute, useValue: { @@ -69,46 +60,5 @@ describe("PasswordHealthComponent", () => { expect(component).toBeTruthy(); }); - it("should populate reportCiphers with ciphers that have password issues", async () => { - passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 1 } as any); - - auditServiceMock.passwordLeaked.mockResolvedValue(5); - - await component.setCiphers(); - - const cipherIds = component.reportCiphers.map((c) => c.id); - - expect(cipherIds).toEqual([ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - ]); - expect(component.reportCiphers.length).toEqual(3); - }); - - it("should correctly populate passwordStrengthMap", async () => { - passwordStrengthService.getPasswordStrength.mockImplementation((password) => { - let score = 0; - if (password === "123") { - score = 1; - } else { - score = 4; - } - return { score } as any; - }); - - auditServiceMock.passwordLeaked.mockResolvedValue(0); - - await component.setCiphers(); - - expect(component.passwordStrengthMap.size).toBeGreaterThan(0); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ - "veryWeak", - "danger", - ]); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ - "veryWeak", - "danger", - ]); - }); + it("should call generateReport on init", () => {}); }); diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.ts index 6e8e62c50db..4b7b8e394d3 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeModule, @@ -43,23 +41,17 @@ import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); dataSource = new TableDataSource(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; - loading = true; private destroyRef = inject(DestroyRef); @@ -67,7 +59,6 @@ export class PasswordHealthComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -77,153 +68,28 @@ export class PasswordHealthComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); } - async setCiphers() { - const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - - // const reportIssues = allCiphers.map((c) => { - // if (this.passwordStrengthMap.has(c.id)) { - // return c; - // } - - // if (this.passwordUseMap.has(c.id)) { - // return c; - // } - - // if (this.exposedPasswordMap.has(c.id)) { - // return c; - // } - // }); - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) || 0 + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } - const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private isUserNameNotEmpty(c: CipherView): boolean { - return !Utils.isNullOrWhitespace(c.login.username); - } - - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts b/apps/web/src/app/tools/access-intelligence/password-health.mock.ts deleted file mode 100644 index d01edc37a59..00000000000 --- a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts +++ /dev/null @@ -1,66 +0,0 @@ -export const userData: any[] = [ - { - userName: "David Brent", - email: "david.brent@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Tim Canterbury", - email: "tim.canterbury@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Gareth Keenan", - email: "gareth.keenan@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - "cbea34a8-bde4-46ad-9d19-b05001227nm7", - ], - }, - { - userName: "Dawn Tinsley", - email: "dawn.tinsley@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, - { - userName: "Keith Bishop", - email: "keith.bishop@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Chris Finch", - email: "chris.finch@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, -]; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b0c897e91e..3799945ea98 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,6 +10,7 @@ "@bitwarden/auth/common": ["../../libs/auth/src/common"], "@bitwarden/auth/angular": ["../../libs/auth/src/angular"], "@bitwarden/billing": ["../../libs/billing/src"], + "@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], diff --git a/bitwarden_license/bit-common/jest.config.js b/bitwarden_license/bit-common/jest.config.js index d79f8ae6199..a0441b01883 100644 --- a/bitwarden_license/bit-common/jest.config.js +++ b/bitwarden_license/bit-common/jest.config.js @@ -1,16 +1,16 @@ const { pathsToModuleNameMapper } = require("ts-jest"); - const { compilerOptions } = require("./tsconfig"); - const sharedConfig = require("../../libs/shared/jest.config.angular"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, displayName: "bit-common tests", - preset: "ts-jest", testEnvironment: "jsdom", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", }), + setupFilesAfterEnv: ["/test.setup.ts"], + transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@angular|rxjs|@bitwarden))"], + moduleFileExtensions: ["ts", "js", "html", "mjs"], }; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts index e69de29bb2d..b2221a94a89 100644 --- a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts @@ -0,0 +1 @@ +export * from "./services"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts new file mode 100644 index 00000000000..22b9148c840 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts @@ -0,0 +1,128 @@ +export const mockCiphers: any[] = [ + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228ab1", + organizationId: null, + folderId: null, + name: "Cannot Be Edited", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + }, + edit: false, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228ab2", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 2", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://nothing.com", + }, + ], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228cd3", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 3", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://example.com", + }, + ], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228xy4", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 4", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "101domain.com" }], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001227nm5", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 5", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "123formbuilder.com" }], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, +]; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts new file mode 100644 index 00000000000..c7bace84e5b --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts @@ -0,0 +1,2 @@ +export * from "./member-cipher-details-api.service"; +export * from "./password-health.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts new file mode 100644 index 00000000000..78cc105e9b4 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts @@ -0,0 +1,68 @@ +export const mockMemberCipherDetailsResponse: { data: any[] } = { + data: [ + { + UserName: "David Brent", + Email: "david.brent@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Tim Canterbury", + Email: "tim.canterbury@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Gareth Keenan", + Email: "gareth.keenan@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + "cbea34a8-bde4-46ad-9d19-b05001227nm7", + ], + }, + { + UserName: "Dawn Tinsley", + Email: "dawn.tinsley@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + { + UserName: "Keith Bishop", + Email: "keith.bishop@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Chris Finch", + Email: "chris.finch@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + ], +}; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts new file mode 100644 index 00000000000..8f391b7d569 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts @@ -0,0 +1,136 @@ +import { TestBed } from "@angular/core/testing"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { mockCiphers } from "./ciphers.mock"; +import { PasswordHealthService } from "./password-health.service"; + +describe("PasswordHealthService", () => { + let service: PasswordHealthService; + let cipherService: CipherService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + PasswordHealthService, + { + provide: PasswordStrengthServiceAbstraction, + useValue: { + getPasswordStrength: (password: string) => { + const score = password.length < 4 ? 1 : 4; + return { score }; + }, + }, + }, + { + provide: AuditService, + useValue: { + passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0), + }, + }, + { + provide: CipherService, + useValue: { + getAllFromApiForOrganization: jest.fn().mockResolvedValue(CipherData), + }, + }, + { provide: "organizationId", useValue: "org1" }, + ], + }); + + service = TestBed.inject(PasswordHealthService); + cipherService = TestBed.inject(CipherService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should initialize properties", () => { + expect(service.reportCiphers).toEqual([]); + expect(service.reportCipherIds).toEqual([]); + expect(service.passwordStrengthMap.size).toBe(0); + expect(service.passwordUseMap.size).toBe(0); + expect(service.exposedPasswordMap.size).toBe(0); + expect(service.totalMembersMap.size).toBe(0); + }); + + describe("generateReport", () => { + beforeEach(async () => { + await service.generateReport(); + }); + + it("should fetch all ciphers for the organization", () => { + expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1"); + }); + + it("should populate reportCiphers with ciphers that have issues", () => { + expect(service.reportCiphers.length).toBeGreaterThan(0); + }); + + it("should detect weak passwords", () => { + expect(service.passwordStrengthMap.size).toBeGreaterThan(0); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([ + "veryWeak", + "danger", + ]); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ + "veryWeak", + "danger", + ]); + expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ + "veryWeak", + "danger", + ]); + }); + + it("should detect reused passwords", () => { + expect(service.passwordUseMap.get("123")).toBe(3); + }); + + it("should detect exposed passwords", () => { + expect(service.exposedPasswordMap.size).toBeGreaterThan(0); + expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100); + }); + + it("should calculate total members per cipher", () => { + expect(service.totalMembersMap.size).toBeGreaterThan(0); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1); + expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6); + }); + }); + + describe("findWeakPassword", () => { + it("should add weak passwords to passwordStrengthMap", () => { + const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; + service.findWeakPassword(weakCipher); + expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]); + }); + }); + + describe("findReusedPassword", () => { + it("should detect password reuse", () => { + mockCiphers.forEach((cipher) => { + service.findReusedPassword(cipher as CipherView); + }); + const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1); + expect(reuseCounts.length).toBeGreaterThan(0); + }); + }); + + describe("findExposedPassword", () => { + it("should add exposed passwords to exposedPasswordMap", async () => { + const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; + await service.findExposedPassword(exposedCipher); + expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts new file mode 100644 index 00000000000..ce78aba426d --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from "@angular/core"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BadgeVariant } from "@bitwarden/components"; + +import { mockCiphers } from "./ciphers.mock"; +import { mockMemberCipherDetailsResponse } from "./member-cipher-details-response.mock"; + +@Injectable() +export class PasswordHealthService { + reportCiphers: CipherView[] = []; + + reportCipherIds: string[] = []; + + passwordStrengthMap = new Map(); + + passwordUseMap = new Map(); + + exposedPasswordMap = new Map(); + + totalMembersMap = new Map(); + + constructor( + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private auditService: AuditService, + private cipherService: CipherService, + @Inject("organizationId") private organizationId: string, + ) {} + + async generateReport() { + let allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId); + // TODO remove when actual user member data is available + allCiphers = mockCiphers; + allCiphers.forEach(async (cipher) => { + this.findWeakPassword(cipher); + this.findReusedPassword(cipher); + await this.findExposedPassword(cipher); + }); + + // TODO - fetch actual user member when data is available + mockMemberCipherDetailsResponse.data.forEach((user) => { + user.cipherIds.forEach((cipherId: string) => { + if (this.totalMembersMap.has(cipherId)) { + this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); + } else { + this.totalMembersMap.set(cipherId, 1); + } + }); + }); + } + + async findExposedPassword(cipher: CipherView) { + const { type, login, isDeleted, viewPassword, id } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + const exposedCount = await this.auditService.passwordLeaked(login.password); + if (exposedCount > 0) { + this.exposedPasswordMap.set(id, exposedCount); + this.checkForExistingCipher(cipher); + } + } + + findReusedPassword(cipher: CipherView) { + const { type, login, isDeleted, viewPassword } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + if (this.passwordUseMap.has(login.password)) { + this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1); + } else { + this.passwordUseMap.set(login.password, 1); + } + + this.checkForExistingCipher(cipher); + } + + findWeakPassword(cipher: CipherView): void { + const { type, login, isDeleted, viewPassword } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + const hasUserName = this.isUserNameNotEmpty(cipher); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + login.username + .substring(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const { score } = this.passwordStrengthService.getPasswordStrength( + login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (score != null && score <= 2) { + this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); + this.checkForExistingCipher(cipher); + } + } + + private isUserNameNotEmpty(c: CipherView): boolean { + return !Utils.isNullOrWhitespace(c.login.username); + } + + private scoreKey(score: number): [string, BadgeVariant] { + switch (score) { + case 4: + return ["strong", "success"]; + case 3: + return ["good", "primary"]; + case 2: + return ["weak", "warning"]; + default: + return ["veryWeak", "danger"]; + } + } + + private checkForExistingCipher(ciph: CipherView) { + if (!this.reportCipherIds.includes(ciph.id)) { + this.reportCipherIds.push(ciph.id); + this.reportCiphers.push(ciph); + } + } +} diff --git a/bitwarden_license/bit-common/test.setup.ts b/bitwarden_license/bit-common/test.setup.ts new file mode 100644 index 00000000000..a702c633967 --- /dev/null +++ b/bitwarden_license/bit-common/test.setup.ts @@ -0,0 +1 @@ +import "jest-preset-angular/setup-jest"; From a8299e7040c4b1e69c941843e84e046e2bcdc705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 14:17:24 -0400 Subject: [PATCH 25/41] fix generate a11y binding (#11671) --- .../generator/components/src/credential-generator.component.ts | 2 +- .../generator/components/src/password-generator.component.ts | 2 +- .../generator/components/src/username-generator.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 25aff97f16c..c57aaadeece 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -456,7 +456,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); /** Emits hint key for the currently selected credential type */ diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index f6ec1b17e2d..ff2cc21d541 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -193,7 +193,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); private toOptions(algorithms: AlgorithmInfo[]) { diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 083ef32a3b0..ea75ef6079c 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -378,7 +378,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ generate }) => generate), ); /** Emits hint key for the currently selected credential type */ From 7c79487f041292a13ca2867ba67b048fe10a63d0 Mon Sep 17 00:00:00 2001 From: SamFrank234 <95505948+SamFrank234@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:21:56 -0400 Subject: [PATCH 26/41] [PM-7565] fix filter icon alignment (#8790) update styles so that folders and subfolders are correctly aligned in vault filters on web and desktop --- apps/desktop/src/scss/left-nav.scss | 2 ++ apps/web/src/scss/vault-filters.scss | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/desktop/src/scss/left-nav.scss b/apps/desktop/src/scss/left-nav.scss index dc882ad265d..4404110ba65 100644 --- a/apps/desktop/src/scss/left-nav.scss +++ b/apps/desktop/src/scss/left-nav.scss @@ -141,6 +141,8 @@ color: themed("headingButtonColor"); } + margin-right: 0.25rem; + &:hover, &:focus { @include themify($themes) { diff --git a/apps/web/src/scss/vault-filters.scss b/apps/web/src/scss/vault-filters.scss index 01c3903c507..27b4b8164f9 100644 --- a/apps/web/src/scss/vault-filters.scss +++ b/apps/web/src/scss/vault-filters.scss @@ -43,6 +43,7 @@ button.toggle-button, button.add-button { + margin-right: 0.25rem; &:hover, &:focus { @include themify($themes) { @@ -98,6 +99,7 @@ } .toggle-button { + margin-right: 0.25rem; &:hover, &:focus { @include themify($themes) { From 22be52d2f3912d911e899ad8c53ac8534047633c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 14:23:28 -0400 Subject: [PATCH 27/41] [PM-12303] fix password state spurious emissions (#11670) * trace generation requests * eliminate spurious save caused by validator changes * fix emissions caused by setting bounds attrbutes --------- Co-authored-by: Daniel James Smith --- .../src/credential-generator.component.html | 16 +++++----- .../src/credential-generator.component.ts | 12 +++++-- .../src/forwarder-settings.component.ts | 2 ++ .../src/passphrase-settings.component.html | 9 +----- .../src/passphrase-settings.component.ts | 11 ++----- .../src/password-generator.component.html | 10 +++--- .../src/password-generator.component.ts | 12 +++++-- .../src/password-settings.component.html | 24 ++------------ .../src/password-settings.component.ts | 31 +------------------ .../src/username-generator.component.html | 16 ++++++---- .../src/username-generator.component.ts | 12 +++++-- 11 files changed, 64 insertions(+), 91 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 4c9fb9e7e49..06ea1f767b7 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -20,9 +20,11 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate$.next()" + (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" - > + > + {{ credentialTypeGenerateLabel$ | async }} +
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index c57aaadeece..a37de986499 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -376,7 +376,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate$.next(); + this.generate("autogenerate"); } }); }); @@ -472,7 +472,15 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { protected readonly userId$ = new BehaviorSubject(null); /** Emits when a new credential is requested */ - protected readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); + + /** Request a new value from the generator + * @param requestor a label used to trace generation request + * origin in the debugger. + */ + protected generate(requestor: string) { + this.generate$.next(requestor); + } private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index a1e6c7acfd8..67e93c611ee 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -143,6 +143,8 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy control.clearValidators(); } } + + this.settings.updateValueAndValidity({ emitEvent: false }); }); // the first emission is the current value; subsequent emissions are updates diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index 2a3f4b5a287..25e9684e864 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -7,14 +7,7 @@ {{ "numWords" | i18n }} - +
diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 82524eba4d8..4c171e0c205 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -91,9 +91,8 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { .get(Controls.wordSeparator) .setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints)); - // forward word boundaries to the template (can't do it through the rx form) - this.minNumWords = constraints.numWords.min; - this.maxNumWords = constraints.numWords.max; + this.settings.updateValueAndValidity({ emitEvent: false }); + this.policyInEffect = constraints.policyInEffect; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); @@ -104,12 +103,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); } - /** attribute binding for numWords[min] */ - protected minNumWords: number; - - /** attribute binding for numWords[max] */ - protected maxNumWords: number; - /** display binding for enterprise policy notice */ protected policyInEffect: boolean; diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index aecdf0f6a4d..96aa8f00b1c 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -18,9 +18,11 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate$.next()" + (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" - > + > + {{ credentialTypeGenerateLabel$ | async }} + + > + {{ credentialTypeGenerateLabel$ | async }} + + > + {{ credentialTypeCopyLabel$ | async }} + @@ -44,7 +48,7 @@ diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index ea75ef6079c..838177d030d 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -313,7 +313,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate$.next(); + this.generate("autogenerate"); } }); }); @@ -391,7 +391,15 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { protected readonly userId$ = new BehaviorSubject(null); /** Emits when a new credential is requested */ - protected readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); + + /** Request a new value from the generator + * @param requestor a label used to trace generation request + * origin in the debugger. + */ + protected generate(requestor: string) { + this.generate$.next(requestor); + } private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ From a2a15d42d5c2501d70fed66e32f5b0210447d405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 14:58:49 -0400 Subject: [PATCH 28/41] add test ids (#11674) --- .../src/credential-generator.component.html | 14 ++++++++++++-- .../src/username-generator.component.html | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 06ea1f767b7..737e32fa1f9 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -56,7 +56,12 @@
{{ "type" | i18n }} - + + {{ credentialTypeHint$ | async }} @@ -65,7 +70,12 @@ {{ "service" | i18n }} - + + {{ "type" | i18n }} - + + {{ credentialTypeHint$ | async }} @@ -42,7 +47,12 @@
{{ "service" | i18n }} - + +
Date: Wed, 23 Oct 2024 15:38:26 -0400 Subject: [PATCH 29/41] [PM-13723] track history in generator components (#11673) * add history support to generator components * increase generator history length --- .../src/credential-generator.component.ts | 11 ++++++- .../src/password-generator.component.ts | 31 ++++++++++++++++++- .../src/username-generator.component.ts | 11 ++++++- .../history/src/generated-credential.ts | 4 +-- .../src/generator-history.abstraction.ts | 4 +-- .../src/local-generator-history.service.ts | 12 +++++-- 6 files changed, 63 insertions(+), 10 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index a37de986499..e800ce4bd39 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -37,6 +37,7 @@ import { isUsernameAlgorithm, toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; +import { GeneratorHistoryService } from "@bitwarden/generator-history"; // constants used to identify navigation selections that are not // generator algorithms @@ -51,6 +52,7 @@ const NONE_SELECTED = "none"; export class CredentialGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private generatorHistoryService: GeneratorHistoryService, private toastService: ToastService, private logService: LogService, private i18nService: I18nService, @@ -182,9 +184,16 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // continue with origin stream return generator; }), + withLatestFrom(this.userId$), takeUntil(this.destroyed), ) - .subscribe((generated) => { + .subscribe(([generated, userId]) => { + this.generatorHistoryService + .track(userId, generated.credential, generated.category, generated.generationDate) + .catch((e: unknown) => { + this.logService.error(e); + }); + // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 96af1b05c13..60c3f629538 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -2,6 +2,7 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { BehaviorSubject, + catchError, distinctUntilChanged, filter, map, @@ -14,7 +15,9 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; import { Option } from "@bitwarden/components/src/select/option"; import { CredentialGeneratorService, @@ -25,6 +28,7 @@ import { isPasswordAlgorithm, AlgorithmInfo, } from "@bitwarden/generator-core"; +import { GeneratorHistoryService } from "@bitwarden/generator-history"; /** Options group for passwords */ @Component({ @@ -34,6 +38,9 @@ import { export class PasswordGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private generatorHistoryService: GeneratorHistoryService, + private toastService: ToastService, + private logService: LogService, private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, @@ -109,10 +116,32 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), + withLatestFrom(this.userId$), takeUntil(this.destroyed), ) - .subscribe((generated) => { + .subscribe(([generated, userId]) => { + this.generatorHistoryService + .track(userId, generated.credential, generated.category, generated.generationDate) + .catch((e: unknown) => { + this.logService.error(e); + }); + // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 838177d030d..7ba4b254e98 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -36,6 +36,7 @@ import { isUsernameAlgorithm, toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; +import { GeneratorHistoryService } from "@bitwarden/generator-history"; // constants used to identify navigation selections that are not // generator algorithms @@ -57,6 +58,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { */ constructor( private generatorService: CredentialGeneratorService, + private generatorHistoryService: GeneratorHistoryService, private toastService: ToastService, private logService: LogService, private i18nService: I18nService, @@ -153,9 +155,16 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // continue with origin stream return generator; }), + withLatestFrom(this.userId$), takeUntil(this.destroyed), ) - .subscribe((generated) => { + .subscribe(([generated, userId]) => { + this.generatorHistoryService + .track(userId, generated.credential, generated.category, generated.generationDate) + .catch((e: unknown) => { + this.logService.error(e); + }); + // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { diff --git a/libs/tools/generator/extensions/history/src/generated-credential.ts b/libs/tools/generator/extensions/history/src/generated-credential.ts index 59a9623bf7e..32efb752258 100644 --- a/libs/tools/generator/extensions/history/src/generated-credential.ts +++ b/libs/tools/generator/extensions/history/src/generated-credential.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { GeneratorCategory } from "./options"; +import { CredentialAlgorithm } from "@bitwarden/generator-core"; /** A credential generation result */ export class GeneratedCredential { @@ -14,7 +14,7 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: GeneratorCategory, + readonly category: CredentialAlgorithm, generationDate: Date | number, ) { if (typeof generationDate === "number") { diff --git a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts index 78144c3043d..06d8741b2e0 100644 --- a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts +++ b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts @@ -1,9 +1,9 @@ import { Observable } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; +import { CredentialAlgorithm } from "@bitwarden/generator-core"; import { GeneratedCredential } from "./generated-credential"; -import { GeneratorCategory } from "./options"; /** Tracks the history of password generations. * Each user gets their own store. @@ -27,7 +27,7 @@ export abstract class GeneratorHistoryService { track: ( userId: UserId, credential: string, - category: GeneratorCategory, + category: CredentialAlgorithm, date?: Date, ) => Promise; diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts index 2416c84b63d..99497f7ad50 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts @@ -8,12 +8,13 @@ import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-pack import { SecretState } from "@bitwarden/common/tools/state/secret-state"; import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; import { UserId } from "@bitwarden/common/types/guid"; +import { CredentialAlgorithm } from "@bitwarden/generator-core"; import { GeneratedCredential } from "./generated-credential"; import { GeneratorHistoryService } from "./generator-history.abstraction"; import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "./key-definitions"; import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; -import { GeneratorCategory, HistoryServiceOptions } from "./options"; +import { HistoryServiceOptions } from "./options"; const OPTIONS_FRAME_SIZE = 2048; @@ -25,7 +26,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { private readonly encryptService: EncryptService, private readonly keyService: CryptoService, private readonly stateProvider: StateProvider, - private readonly options: HistoryServiceOptions = { maxTotal: 100 }, + private readonly options: HistoryServiceOptions = { maxTotal: 200 }, ) { super(); } @@ -33,7 +34,12 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { private _credentialStates = new Map>(); /** {@link GeneratorHistoryService.track} */ - track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => { + track = async ( + userId: UserId, + credential: string, + category: CredentialAlgorithm, + date?: Date, + ) => { const state = this.getCredentialState(userId); let result: GeneratedCredential = null; From 9b471e663336af838f65eb2486dfbcbf922cb940 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Thu, 24 Oct 2024 08:22:43 -0500 Subject: [PATCH 30/41] [PM-13715] Launching a website from the extension does not trigger an update to reference the correct autofill value (#11587) * [PM-13715] Launching page from cipher does not set correct autofill action * [PM-13715] Fix autofill not triggering for correct cipher after page has been launched from browser extension --- .../popup/components/vault/view.component.html | 2 +- libs/angular/src/vault/components/view.component.ts | 6 ++---- libs/common/src/vault/services/cipher.service.ts | 13 +++++-------- .../autofill-options-view.component.ts | 10 ++++++++-- .../src/cipher-view/cipher-view.component.html | 6 +++++- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault/view.component.html b/apps/browser/src/vault/popup/components/vault/view.component.html index ff76c68464d..73415c9070a 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.html +++ b/apps/browser/src/vault/popup/components/vault/view.component.html @@ -472,7 +472,7 @@ attr.aria-label="{{ 'launch' | i18n }} {{ u.uri }}" appA11yTitle="{{ 'launch' | i18n }}" *ngIf="u.canLaunch" - (click)="launch(u)" + (click)="launch(u, cipher.id)" > diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 4c96c10dac3..2ff34ebafa5 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -348,15 +348,13 @@ export class ViewComponent implements OnDestroy, OnInit { } } - launch(uri: Launchable, cipherId?: string) { + async launch(uri: Launchable, cipherId?: string) { if (!uri.canLaunch) { return; } if (cipherId) { - // 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.cipherService.updateLastLaunchedDate(cipherId); + await this.cipherService.updateLastLaunchedDate(cipherId); } this.platformUtilsService.launchUri(uri.launchUri); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index a7377a93eec..207a5da3cbf 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -650,14 +650,11 @@ export class CipherService implements CipherServiceAbstraction { ciphersLocalData = {}; } - const cipherId = id as CipherId; - if (ciphersLocalData[cipherId]) { - ciphersLocalData[cipherId].lastLaunched = new Date().getTime(); - } else { - ciphersLocalData[cipherId] = { - lastUsedDate: new Date().getTime(), - }; - } + const currentTime = new Date().getTime(); + ciphersLocalData[id as CipherId] = { + lastLaunched: currentTime, + lastUsedDate: currentTime, + }; await this.localDataState.update(() => ciphersLocalData); diff --git a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts index b7708b5aa98..2c3739dba41 100644 --- a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts +++ b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts @@ -3,6 +3,7 @@ import { Component, Input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { CardComponent, @@ -30,10 +31,15 @@ import { }) export class AutofillOptionsViewComponent { @Input() loginUris: LoginUriView[]; + @Input() cipherId: string; - constructor(private platformUtilsService: PlatformUtilsService) {} + constructor( + private platformUtilsService: PlatformUtilsService, + private cipherService: CipherService, + ) {} - openWebsite(selectedUri: string) { + async openWebsite(selectedUri: string) { + await this.cipherService.updateLastLaunchedDate(this.cipherId); this.platformUtilsService.launchUri(selectedUri); } } diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index b693c448158..2dd98092cbf 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -25,7 +25,11 @@ - + From b3b311e164bf677d3b589cb0a72fe6968044610b Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 24 Oct 2024 15:43:49 +0200 Subject: [PATCH 31/41] Add logging for decryption failures (#11683) * Add logging to decryption routines * Fix case of uknown encryption type * Remove enum to string mapping --- .../platform/enums/encryption-type.enum.ts | 8 +++ .../encrypt.service.implementation.ts | 59 +++++++++++++++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/libs/common/src/platform/enums/encryption-type.enum.ts b/libs/common/src/platform/enums/encryption-type.enum.ts index b4ecd780499..a0ffe679279 100644 --- a/libs/common/src/platform/enums/encryption-type.enum.ts +++ b/libs/common/src/platform/enums/encryption-type.enum.ts @@ -8,6 +8,14 @@ export enum EncryptionType { Rsa2048_OaepSha1_HmacSha256_B64 = 6, } +export function encryptionTypeToString(encryptionType: EncryptionType): string { + if (encryptionType in EncryptionType) { + return EncryptionType[encryptionType]; + } else { + return "Unknown encryption type " + encryptionType; + } +} + /** The expected number of parts to a serialized EncString of the given encryption type. * For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type * AesCbc128_HmacSha256_B64 will have 3 parts. diff --git a/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts b/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts index 681972e7e4b..8c42c724b24 100644 --- a/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts +++ b/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts @@ -2,7 +2,7 @@ import { Utils } from "../../../platform/misc/utils"; import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { EncryptService } from "../../abstractions/encrypt.service"; import { LogService } from "../../abstractions/log.service"; -import { EncryptionType } from "../../enums"; +import { EncryptionType, encryptionTypeToString as encryptionTypeName } from "../../enums"; import { Decryptable } from "../../interfaces/decryptable.interface"; import { Encrypted } from "../../interfaces/encrypted"; import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface"; @@ -70,13 +70,24 @@ export class EncryptServiceImplementation implements EncryptService { key = this.resolveLegacyKey(key, encString); + // DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality. if (key.macKey != null && encString?.mac == null) { - this.logService.error("MAC required but not provided."); + this.logService.error( + "[Encrypt service] Key has mac key but payload is missing mac bytes. Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encString.encryptionType), + ); return null; } if (key.encType !== encString.encryptionType) { - this.logService.error("Key encryption type does not match payload encryption type."); + this.logService.error( + "[Encrypt service] Key encryption type does not match payload encryption type. Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encString.encryptionType), + ); return null; } @@ -94,7 +105,12 @@ export class EncryptServiceImplementation implements EncryptService { ); const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); if (!macsEqual) { - this.logMacFailed("MAC comparison failed. Key or payload has changed."); + this.logMacFailed( + "[Encrypt service] MAC comparison failed. Key or payload has changed. Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encString.encryptionType), + ); return null; } } @@ -113,13 +129,24 @@ export class EncryptServiceImplementation implements EncryptService { key = this.resolveLegacyKey(key, encThing); + // DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality. if (key.macKey != null && encThing.macBytes == null) { - this.logService.error("MAC required but not provided."); + this.logService.error( + "[Encrypt service] Key has mac key but payload is missing mac bytes. Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encThing.encryptionType), + ); return null; } if (key.encType !== encThing.encryptionType) { - this.logService.error("Key encryption type does not match payload encryption type."); + this.logService.error( + "[Encrypt service] Key encryption type does not match payload encryption type. Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encThing.encryptionType), + ); return null; } @@ -129,13 +156,25 @@ export class EncryptServiceImplementation implements EncryptService { macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength); const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256"); if (computedMac === null) { - this.logMacFailed("Failed to compute MAC."); + this.logMacFailed( + "[Encrypt service] Failed to compute MAC." + + " Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encThing.encryptionType), + ); return null; } const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac); if (!macsMatch) { - this.logMacFailed("MAC comparison failed. Key or payload has changed."); + this.logMacFailed( + "[Encrypt service] MAC comparison failed. Key or payload has changed." + + " Key type " + + encryptionTypeName(key.encType) + + " Payload type " + + encryptionTypeName(encThing.encryptionType), + ); return null; } } @@ -164,7 +203,7 @@ export class EncryptServiceImplementation implements EncryptService { async rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise { if (data == null) { - throw new Error("No data provided for decryption."); + throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption."); } let algorithm: "sha1" | "sha256"; @@ -182,7 +221,7 @@ export class EncryptServiceImplementation implements EncryptService { } if (privateKey == null) { - throw new Error("No private key provided for decryption."); + throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption."); } return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm); From 15c301d39f94b71b08a6310c88b036f51609d4d9 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Thu, 24 Oct 2024 10:15:24 -0400 Subject: [PATCH 32/41] Do not redirect after saving changes to excluded domains (#11676) --- .../settings/excluded-domains.component.html | 10 ++- .../settings/excluded-domains.component.ts | 81 ++++++++++++------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 1dec438fdd2..e3b6bf5f802 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -11,7 +11,7 @@ accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n) }}

- +

{{ "domainsTitle" | i18n }}

{{ excludedDomainsState?.length || 0 }} @@ -57,7 +57,13 @@
- diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 381ba903423..f622312ce04 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -1,13 +1,19 @@ import { CommonModule } from "@angular/common"; -import { QueryList, Component, ElementRef, OnDestroy, OnInit, ViewChildren } from "@angular/core"; +import { + QueryList, + Component, + ElementRef, + OnDestroy, + AfterViewInit, + ViewChildren, +} from "@angular/core"; import { FormsModule } from "@angular/forms"; -import { Router, RouterModule } from "@angular/router"; -import { firstValueFrom, Subject, takeUntil } from "rxjs"; +import { RouterModule } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -29,8 +35,6 @@ import { PopupFooterComponent } from "../../../platform/popup/layout/popup-foote import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -const BroadcasterSubscriptionId = "excludedDomainsState"; - @Component({ selector: "app-excluded-domains", templateUrl: "excluded-domains.component.html", @@ -55,11 +59,12 @@ const BroadcasterSubscriptionId = "excludedDomainsState"; TypographyModule, ], }) -export class ExcludedDomainsComponent implements OnInit, OnDestroy { +export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList>; accountSwitcherEnabled = false; dataIsPristine = true; + isLoading = false; excludedDomainsState: string[] = []; storedExcludedDomains: string[] = []; // How many fields should be non-editable before editable fields @@ -70,16 +75,27 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { constructor( private domainSettingsService: DomainSettingsService, private i18nService: I18nService, - private router: Router, - private broadcasterService: BroadcasterService, private platformUtilsService: PlatformUtilsService, ) { this.accountSwitcherEnabled = enableAccountSwitching(); } - async ngOnInit() { - const neverDomains = await firstValueFrom(this.domainSettingsService.neverDomains$); + async ngAfterViewInit() { + this.domainSettingsService.neverDomains$ + .pipe(takeUntil(this.destroy$)) + .subscribe((neverDomains: NeverDomains) => this.handleStateUpdate(neverDomains)); + this.uriInputElements.changes.pipe(takeUntil(this.destroy$)).subscribe(({ last }) => { + this.focusNewUriInput(last); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + handleStateUpdate(neverDomains: NeverDomains) { if (neverDomains) { this.storedExcludedDomains = Object.keys(neverDomains); } @@ -89,15 +105,8 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { // Do not allow the first x (pre-existing) fields to be edited this.fieldsEditThreshold = this.storedExcludedDomains.length; - this.uriInputElements.changes.pipe(takeUntil(this.destroy$)).subscribe(({ last }) => { - this.focusNewUriInput(last); - }); - } - - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.destroy$.next(); - this.destroy$.complete(); + this.dataIsPristine = true; + this.isLoading = false; } focusNewUriInput(elementRef: ElementRef) { @@ -116,7 +125,7 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { async removeDomain(i: number) { this.excludedDomainsState.splice(i, 1); - // if a pre-existing field was dropped, lower the edit threshold + // If a pre-existing field was dropped, lower the edit threshold if (i < this.fieldsEditThreshold) { this.fieldsEditThreshold--; } @@ -132,11 +141,11 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { async saveChanges() { if (this.dataIsPristine) { - await this.router.navigate(["/notifications"]); - return; } + this.isLoading = true; + const newExcludedDomainsSaveState: NeverDomains = {}; const uniqueExcludedDomains = new Set(this.excludedDomainsState); @@ -151,6 +160,8 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { this.i18nService.t("excludedDomainsInvalidDomain", uri), ); + // Don't reset via `handleStateUpdate` to allow existing input value correction + this.isLoading = false; return; } @@ -159,7 +170,23 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { } try { - await this.domainSettingsService.setNeverDomains(newExcludedDomainsSaveState); + const existingState = new Set(this.storedExcludedDomains); + const newState = new Set(Object.keys(newExcludedDomainsSaveState)); + const stateIsUnchanged = + existingState.size === newState.size && + new Set([...existingState, ...newState]).size === existingState.size; + + // The subscriber updates don't trigger if `setNeverDomains` sets an equivalent state + if (stateIsUnchanged) { + // Reset UI state directly + const constructedNeverDomainsState = this.storedExcludedDomains.reduce( + (neverDomains, uri) => ({ ...neverDomains, [uri]: null }), + {}, + ); + this.handleStateUpdate(constructedNeverDomainsState); + } else { + await this.domainSettingsService.setNeverDomains(newExcludedDomainsSaveState); + } this.platformUtilsService.showToast( "success", @@ -169,11 +196,9 @@ export class ExcludedDomainsComponent implements OnInit, OnDestroy { } catch { this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); - // Do not navigate on error - return; + // Don't reset via `handleStateUpdate` to preserve input values + this.isLoading = false; } - - await this.router.navigate(["/notifications"]); } trackByFunction(index: number, _: string) { From 548abfe906acdab3b6408c4efdd0179bb2baac26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:39:41 +0100 Subject: [PATCH 33/41] [PM-12758] Add managed status to OrganizationUserDetailsResponse, OrganizationUserUserDetailsResponse, OrganizationUserView and OrganizationUserAdminView (#11640) * Add managedByOrganization property to OrganizationUserUserDetailsResponse and OrganizationUserView * Add managedByOrganization property to OrganizationUserDetailsResponse and OrganizationUserAdminView * Move response mapping from UserAdminService to method in OrganizationUserAdminView --- .../core/services/user-admin.service.ts | 42 +------------------ .../views/organization-user-admin-view.ts | 34 ++++++++++++++- .../core/views/organization-user.view.ts | 1 + .../responses/organization-user.response.ts | 5 +++ 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts index 9741758e1e0..61fc27614d1 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts @@ -4,19 +4,14 @@ import { OrganizationUserApiService, OrganizationUserInviteRequest, OrganizationUserUpdateRequest, - OrganizationUserDetailsResponse, } from "@bitwarden/admin-console/common"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CoreOrganizationModule } from "../core-organization.module"; import { OrganizationUserAdminView } from "../views/organization-user-admin-view"; @Injectable({ providedIn: CoreOrganizationModule }) export class UserAdminService { - constructor( - private configService: ConfigService, - private organizationUserApiService: OrganizationUserApiService, - ) {} + constructor(private organizationUserApiService: OrganizationUserApiService) {} async get( organizationId: string, @@ -34,9 +29,7 @@ export class UserAdminService { return undefined; } - const [view] = await this.decryptMany(organizationId, [userResponse]); - - return view; + return OrganizationUserAdminView.fromResponse(organizationId, userResponse); } async save(user: OrganizationUserAdminView): Promise { @@ -65,35 +58,4 @@ export class UserAdminService { await this.organizationUserApiService.postOrganizationUserInvite(user.organizationId, request); } - - private async decryptMany( - organizationId: string, - users: OrganizationUserDetailsResponse[], - ): Promise { - const promises = users.map(async (u) => { - const view = new OrganizationUserAdminView(); - - view.id = u.id; - view.organizationId = organizationId; - view.userId = u.userId; - view.type = u.type; - view.status = u.status; - view.externalId = u.externalId; - view.permissions = u.permissions; - view.resetPasswordEnrolled = u.resetPasswordEnrolled; - view.collections = u.collections.map((c) => ({ - id: c.id, - hidePasswords: c.hidePasswords, - readOnly: c.readOnly, - manage: c.manage, - })); - view.groups = u.groups; - view.accessSecretsManager = u.accessSecretsManager; - view.hasMasterPassword = u.hasMasterPassword; - - return view; - }); - - return await Promise.all(promises); - } } diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts index b9b034b405d..63a8c938e24 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts @@ -1,4 +1,7 @@ -import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; +import { + CollectionAccessSelectionView, + OrganizationUserDetailsResponse, +} from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType, OrganizationUserType, @@ -15,9 +18,38 @@ export class OrganizationUserAdminView { permissions: PermissionsApi; resetPasswordEnrolled: boolean; hasMasterPassword: boolean; + managedByOrganization: boolean; collections: CollectionAccessSelectionView[] = []; groups: string[] = []; accessSecretsManager: boolean; + + static fromResponse( + organizationId: string, + response: OrganizationUserDetailsResponse, + ): OrganizationUserAdminView { + const view = new OrganizationUserAdminView(); + + view.id = response.id; + view.organizationId = organizationId; + view.userId = response.userId; + view.type = response.type; + view.status = response.status; + view.externalId = response.externalId; + view.permissions = response.permissions; + view.resetPasswordEnrolled = response.resetPasswordEnrolled; + view.collections = response.collections.map((c) => ({ + id: c.id, + hidePasswords: c.hidePasswords, + readOnly: c.readOnly, + manage: c.manage, + })); + view.groups = response.groups; + view.accessSecretsManager = response.accessSecretsManager; + view.hasMasterPassword = response.hasMasterPassword; + view.managedByOrganization = response.managedByOrganization; + + return view; + } } diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts index 7d1a10c5332..eac80dd0242 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts @@ -25,6 +25,7 @@ export class OrganizationUserView { * True if this organizaztion user has been granted access to Secrets Manager, false otherwise. */ accessSecretsManager: boolean; + managedByOrganization: boolean; collections: CollectionAccessSelectionView[] = []; groups: string[] = []; diff --git a/libs/admin-console/src/common/organization-user/models/responses/organization-user.response.ts b/libs/admin-console/src/common/organization-user/models/responses/organization-user.response.ts index 7323855f69f..f61d9325c2a 100644 --- a/libs/admin-console/src/common/organization-user/models/responses/organization-user.response.ts +++ b/libs/admin-console/src/common/organization-user/models/responses/organization-user.response.ts @@ -49,6 +49,7 @@ export class OrganizationUserUserDetailsResponse extends OrganizationUserRespons avatarColor: string; twoFactorEnabled: boolean; usesKeyConnector: boolean; + managedByOrganization: boolean; constructor(response: any) { super(response); @@ -57,12 +58,16 @@ export class OrganizationUserUserDetailsResponse extends OrganizationUserRespons this.avatarColor = this.getResponseProperty("AvatarColor"); this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector") ?? false; + this.managedByOrganization = this.getResponseProperty("ManagedByOrganization") ?? false; } } export class OrganizationUserDetailsResponse extends OrganizationUserResponse { + managedByOrganization: boolean; + constructor(response: any) { super(response); + this.managedByOrganization = this.getResponseProperty("ManagedByOrganization") ?? false; } } From 44e182e32ece3af4234a3d9173a9b98e740870d3 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 24 Oct 2024 10:30:46 -0500 Subject: [PATCH 34/41] [PM-13187] Hide "Assign To Collections" when the user has no orgs (#11668) * web - hide assign to collections button when the user has no organizations * browser - hide assign to collections button when the user has no organizations * hide assign to collections in the bulk edit menu when the user doesn't belong to an organization --- .../item-more-options.component.html | 2 +- .../item-more-options.component.ts | 13 +++++++++++-- .../vault-items/vault-cipher-row.component.ts | 2 +- .../components/vault-items/vault-items.component.ts | 5 +++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 8374bb0cc79..03287c75fa9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -28,7 +28,7 @@ {{ "clone" | i18n }} - + {{ "assignToCollections" | i18n }}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 2bc3fcea2f9..8c52ee7ed84 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -1,9 +1,10 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, Input, OnInit } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,7 +30,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; templateUrl: "./item-more-options.component.html", imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) -export class ItemMoreOptionsComponent { +export class ItemMoreOptionsComponent implements OnInit { @Input({ required: true, }) @@ -44,6 +45,9 @@ export class ItemMoreOptionsComponent { protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; + /** Boolean dependent on the current user having access to an organization */ + protected hasOrganizations = false; + constructor( private cipherService: CipherService, private passwordRepromptService: PasswordRepromptService, @@ -53,8 +57,13 @@ export class ItemMoreOptionsComponent { private i18nService: I18nService, private vaultPopupAutofillService: VaultPopupAutofillService, private accountService: AccountService, + private organizationService: OrganizationService, ) {} + async ngOnInit(): Promise { + this.hasOrganizations = await this.organizationService.hasOrganizations(); + } + get canEdit() { return this.cipher.edit; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 67b02d364f5..355f0240ece 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -92,7 +92,7 @@ export class VaultCipherRowComponent implements OnInit { } protected get showAssignToCollections() { - return this.canEditCipher && !this.cipher.isDeleted; + return this.organizations?.length && this.canEditCipher && !this.cipher.isDeleted; } protected get showClone() { 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 f469cec75a0..db1c2d6b567 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 @@ -299,6 +299,11 @@ export class VaultItemsComponent { return false; } + // When the user doesn't belong to an organization, hide assign to collections + if (this.allOrganizations.length === 0) { + return false; + } + if (this.selection.selected.length === 0) { return true; } From ed4071c7d463ab252017d10e45732b6a528db53b Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 24 Oct 2024 12:06:08 -0400 Subject: [PATCH 35/41] Revert [PM-11312] Add "prevent screenshot" setting (#11685) This reverts commit 1b7bb014d203d23e9ac391382941a563cf0132b7. --- .../src/app/accounts/settings.component.html | 17 ----------------- .../src/app/accounts/settings.component.ts | 6 ------ apps/desktop/src/locales/en/messages.json | 6 ------ apps/desktop/src/main/window.main.ts | 15 --------------- .../services/desktop-settings.service.ts | 19 ------------------- 5 files changed, 63 deletions(-) diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 02b64a757a7..76cf98b1b24 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -419,23 +419,6 @@ "enableHardwareAccelerationDesc" | i18n }} -
-
- -
- {{ - "allowScreenshotsDesc" | i18n - }} -