From 789d66ce880933c1699545c3b1000e549359ca6a Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:19:36 -0500 Subject: [PATCH 01/25] =?UTF-8?q?[PM-22840]=C2=A0Update=20collection=20man?= =?UTF-8?q?agement=20settings=20copy=20(#16142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update collection management settings, refs PM-22840 * Split the item deletion restriction description message into three parts to allow for italicized text in the middle. --------- Co-authored-by: Rui Tome Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> --- .../settings/account.component.html | 14 +++++++---- apps/web/src/locales/en/messages.json | 25 +++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index 4ce4398aadc..943454781db 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -57,21 +57,25 @@ [formGroup]="collectionManagementFormGroup" >

{{ "collectionManagement" | i18n }}

-

{{ "collectionManagementDesc" | i18n }}

+

{{ "collectionManagementDescription" | i18n }}

- {{ "allowAdminAccessToAllCollectionItemsDesc" | i18n }} + {{ "allowAdminAccessToAllCollectionItemsDescription" | i18n }} - {{ "limitCollectionCreationDesc" | i18n }} + {{ "restrictCollectionCreationDescription" | i18n }} - {{ "limitCollectionDeletionDesc" | i18n }} + {{ "restrictCollectionDeletionDescription" | i18n }} - {{ "limitItemDeletionDescription" | i18n }} + + {{ "restrictItemDeletionDescriptionStart" | i18n }} + {{ "manageCollection" | i18n }} + {{ "restrictItemDeletionDescriptionEnd" | i18n }} + +
+ +
+
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index 1409aea0cb2..ebb38576813 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; @@ -33,6 +35,7 @@ describe("VaultCarouselComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }).compileComponents(); }); @@ -48,7 +51,7 @@ describe("VaultCarouselComponent", () => { it("shows the active slides content", () => { // Set the second slide as active - fixture.debugElement.queryAll(By.css("button"))[1].nativeElement.click(); + fixture.debugElement.queryAll(By.css("button"))[2].nativeElement.click(); fixture.detectChanges(); const heading = fixture.debugElement.query(By.css("h1")).nativeElement; @@ -63,10 +66,37 @@ describe("VaultCarouselComponent", () => { it('emits "slideChange" event when slide changes', () => { jest.spyOn(component.slideChange, "emit"); - const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[3]; thirdSlideButton.nativeElement.click(); expect(component.slideChange.emit).toHaveBeenCalledWith(2); }); + + it('advances to the next slide when the "next" button is pressed', () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const nextButton = fixture.debugElement.queryAll(By.css("button"))[4]; + + middleSlideButton.nativeElement.click(); + + jest.spyOn(component.slideChange, "emit"); + + nextButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(2); + }); + + it('advances to the previous slide when the "back" button is pressed', async () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; + + middleSlideButton.nativeElement.click(); + await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. + + jest.spyOn(component.slideChange, "emit"); + + backButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(0); + }); }); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index f2d211697df..fdebbebc33b 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -20,7 +20,9 @@ import { import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { take } from "rxjs"; -import { ButtonModule } from "@bitwarden/components"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, IconButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -32,9 +34,12 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com imports: [ CdkPortalOutlet, CommonModule, + JslibModule, + IconButtonModule, ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, + I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { @@ -97,6 +102,18 @@ export class VaultCarouselComponent implements AfterViewInit { this.slideChange.emit(index); } + protected nextSlide() { + if (this.selectedIndex < this.slides.length - 1) { + this.selectSlide(this.selectedIndex + 1); + } + } + + protected prevSlide() { + if (this.selectedIndex > 0) { + this.selectSlide(this.selectedIndex - 1); + } + } + async ngAfterViewInit() { this.keyManager = new FocusKeyManager(this.carouselButtons) .withHorizontalOrientation("ltr") diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts index 521a561a19f..1e393779a6a 100644 --- a/libs/vault/src/components/carousel/carousel.stories.ts +++ b/libs/vault/src/components/carousel/carousel.stories.ts @@ -1,5 +1,6 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonComponent, TypographyModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; @@ -11,6 +12,7 @@ export default { decorators: [ moduleMetadata({ imports: [VaultCarouselSlideComponent, TypographyModule, ButtonComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }), ], } as Meta; From a4fca832f31f626cf06ce7e63268e2ce00c3d5d8 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 2 Sep 2025 15:15:38 -0400 Subject: [PATCH 06/25] [PM-22312] Resolve TS 5.8 errors (#16108) * refactor: remove ts-strict-ignore and update * refactor: remove ts-strict-ignore and update * refactor: simplify if statement * refactor: remove ts-strict-ignore and update * refactor: add nullable union for interfaces --- .../trial-billing-step.component.ts | 2 +- ...organization-payment-method.component.html | 2 +- .../organization-payment-method.component.ts | 53 ++++++++------- .../adjust-payment-dialog.component.ts | 2 +- .../shared/payment-method.component.ts | 64 +++++++++++-------- .../complete-trial-initiation.component.html | 6 +- .../complete-trial-initiation.component.ts | 51 +++++++++------ 7 files changed, 105 insertions(+), 75 deletions(-) diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index 7e25a422477..20e69cf3bfd 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -42,7 +42,7 @@ export type TrialOrganizationType = Exclude - {{ freeTrialData.message }} + {{ freeTrialData?.message }} a?.id)), ); + + if (!userId) { + throw new Error("User ID is not found"); + } + const organizationPromise = await firstValueFrom( this.organizationService .organizations$(userId) @@ -173,15 +176,20 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { organizationSubscriptionPromise, organizationPromise, ]); + + if (!this.organization) { + throw new Error("Organization is not found"); + } + if (!this.paymentSource) { + throw new Error("Payment source is not found"); + } + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( this.organization, this.organizationSubscriptionResponse, - paymentSource, + this.paymentSource, ); } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; // If the flag `launchPaymentModalAutomatically` is set to true, // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. // This delay ensures that any prior UI/rendering operations complete before triggering the modal. @@ -219,14 +227,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { data: { organizationId: this.organizationId, - subscription: this.organizationSubscriptionResponse, - productTierType: this.organization?.productTierType, + subscription: this.organizationSubscriptionResponse!, + productTierType: this.organization!.productTierType, }, }); const result = await lastValueFrom(dialogRef.closed); if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { await this.syncService.fullSync(true); } this.launchPaymentModalAutomatically = false; @@ -238,13 +246,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("verifiedBankAccount"), }); }; protected get accountCreditHeaderText(): string { - const key = this.accountCredit <= 0 ? "accountBalance" : "accountCredit"; + const hasAccountCredit = this.accountCredit && this.accountCredit > 0; + const key = hasAccountCredit ? "accountCredit" : "accountBalance"; return this.i18nService.t(key); } @@ -279,7 +288,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { if (!hasBillingAddress) { this.toastService.showToast({ variant: "error", - title: null, + title: "", message: this.i18nService.t("billingAddressRequiredToAddCredit"), }); return false; diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts index 94929c58656..9944085488f 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts @@ -24,7 +24,7 @@ import { import { PaymentComponent } from "../payment/payment.component"; export interface AdjustPaymentDialogParams { - initialPaymentMethod?: PaymentMethodType; + initialPaymentMethod?: PaymentMethodType | null; organizationId?: string; productTier?: ProductTierType; providerId?: string; diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 0e116b4f39a..91d5925669a 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; @@ -42,21 +40,21 @@ import { export class PaymentMethodComponent implements OnInit, OnDestroy { loading = false; firstLoaded = false; - billing: BillingPaymentResponse; - org: OrganizationSubscriptionResponse; - sub: SubscriptionResponse; + billing?: BillingPaymentResponse; + org?: OrganizationSubscriptionResponse; + sub?: SubscriptionResponse; paymentMethodType = PaymentMethodType; - organizationId: string; + organizationId?: string; isUnpaid = false; - organization: Organization; + organization?: Organization; verifyBankForm = this.formBuilder.group({ - amount1: new FormControl(null, [ + amount1: new FormControl(0, [ Validators.required, Validators.max(99), Validators.min(0), ]), - amount2: new FormControl(null, [ + amount2: new FormControl(0, [ Validators.required, Validators.max(99), Validators.min(0), @@ -64,7 +62,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { }); launchPaymentModalAutomatically = false; - protected freeTrialData: FreeTrial; + protected freeTrialData?: FreeTrial; constructor( protected apiService: ApiService, @@ -84,7 +82,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { private configService: ConfigService, ) { const state = this.router.getCurrentNavigation()?.extras?.state; - // incase the above state is undefined or null we use redundantState + // In case the above state is undefined or null, we use redundantState const redundantState: any = location.getState(); if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; @@ -129,17 +127,23 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { } this.loading = true; if (this.forOrganization) { - const billingPromise = this.organizationApiService.getBilling(this.organizationId); + const billingPromise = this.organizationApiService.getBilling(this.organizationId!); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, + this.organizationId!, ); + const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + + if (!userId) { + throw new Error("User ID is not found"); + } + const organizationPromise = await firstValueFrom( this.organizationService .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), + .pipe(getOrganizationById(this.organizationId!)), ); [this.billing, this.org, this.organization] = await Promise.all([ @@ -171,14 +175,16 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { }; addCredit = async () => { - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AddCreditDialogResult.Added) { - await this.load(); + if (this.forOrganization) { + const dialogRef = openAddCreditDialog(this.dialogService, { + data: { + organizationId: this.organizationId!, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AddCreditDialogResult.Added) { + await this.load(); + } } }; @@ -194,7 +200,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { if (result === AdjustPaymentDialogResultType.Submitted) { this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { await this.syncService.fullSync(true); } this.launchPaymentModalAutomatically = false; @@ -208,18 +214,22 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { } const request = new VerifyBankRequest(); - request.amount1 = this.verifyBankForm.value.amount1; - request.amount2 = this.verifyBankForm.value.amount2; - await this.organizationApiService.verifyBank(this.organizationId, request); + request.amount1 = this.verifyBankForm.value.amount1!; + request.amount2 = this.verifyBankForm.value.amount2!; + await this.organizationApiService.verifyBank(this.organizationId!, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("verifiedBankAccount"), }); await this.load(); }; determineOrgsWithUpcomingPaymentIssues() { + if (!this.organization || !this.org || !this.billing) { + throw new Error("Organization, organization subscription, or billing is not defined"); + } + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( this.organization, this.org, diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index e74997cb9f5..c1a33a4c8df 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -37,7 +37,7 @@ bitButton buttonType="primary" [disabled]="orgInfoFormGroup.controls.name.invalid" - [loading]="loading && (trialPaymentOptional$ | async)" + [loading]="loading && (trialPaymentOptional$ | async)!" (click)="orgNameEntrySubmit()" > {{ @@ -55,8 +55,8 @@ { + .catch((e: unknown): null => { this.validationService.showError(e); this.submitting = false; return null; From 5967cf05394c8ef65c4a7c4cd6039e6d8a32b940 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:09:20 -0500 Subject: [PATCH 07/25] [PM-14571] At Risk Passwords - Badge Update (#15983) * add exclamation badge for at risk passwords on tab * add berry icon for the badge when pending tasks are present * remove integration wtih autofill for pending task badge * add ability to override Never match strategy - This is helpful for non-autofill purposes but cipher matching is still needed. This will default to the domain. * add at-risk-cipher badge updater service * Revert "add exclamation badge for at risk passwords on tab" This reverts commit a9643c03d5ff812a88d554b4a4bcb13f0d5444f0. * remove nullish-coalescing * ensure that all user related observables use the same user.id --------- Co-authored-by: Shane Melton --- .../browser/src/background/main.background.ts | 11 ++ apps/browser/src/images/berry19.png | Bin 0 -> 1702 bytes apps/browser/src/images/berry38.png | Bin 0 -> 1244 bytes apps/browser/src/platform/badge/icon.ts | 4 + ...-risk-cipher-badge-updater.service.spec.ts | 84 +++++++++ .../at-risk-cipher-badge-updater.service.ts | 163 ++++++++++++++++++ .../src/vault/abstractions/cipher.service.ts | 4 + .../vault/models/view/login-uri-view.spec.ts | 27 +++ .../src/vault/models/view/login-uri.view.ts | 8 + .../src/vault/models/view/login.view.ts | 6 +- .../src/vault/services/cipher.service.ts | 11 +- .../src/vault/utils/cipher-view-like-utils.ts | 10 +- 12 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 apps/browser/src/images/berry19.png create mode 100644 apps/browser/src/images/berry38.png create mode 100644 apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts create mode 100644 apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index df29502edeb..75481dde8cf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; +import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service"; import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; @@ -433,6 +434,7 @@ export default class MainBackground { badgeService: BadgeService; authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService; autofillBadgeUpdaterService: AutofillBadgeUpdaterService; + atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -1838,6 +1840,14 @@ export default class MainBackground { this.logService, ); + this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService( + this.badgeService, + this.accountService, + this.cipherService, + this.logService, + this.taskService, + ); + this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -1847,6 +1857,7 @@ export default class MainBackground { await this.overlayBackground.init(); await this.tabsBackground.init(); await this.autofillBadgeUpdaterService.init(); + await this.atRiskCipherUpdaterService.init(); } generatePassword = async (): Promise => { diff --git a/apps/browser/src/images/berry19.png b/apps/browser/src/images/berry19.png new file mode 100644 index 0000000000000000000000000000000000000000..51deb3b8d68de97ce8521d376b2823eaecd12824 GIT binary patch literal 1702 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQmUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lxRtf@J#ddWzYh$IT%lpi<;HsXMd|v6mX?>t*GR!Iveo=5iVsfgTA=EA;AtcoxS6lfPWu^iH6clVA| zx>w8XSLgoSaxLxF3?HMJAMbFkT&z^}F!kM@-|uD?zqi%r+Vb-D{cEorudZS)mCp_3 zxjyx6ebmHvYKs|5%@cRFtF6}%^>770$x;Qincd%%qGvf;DyTuzVOG?w^o5@-lCZhs!@`GSXQiR(1)tS*D? zcMi;1d8*>$O_!C9GHIgKx{s!B-dR(~XI^sc$p*Pz2HR$_YuD?Ul3KU~-um%quH1UL=|5_p-ww1rUeG%8%l%@voCVvQ4u72O`6f%&;X3=O z$=h-q1<%RN>7L;8v0w3IZKVDWmtj-N zt%Hw$NL1NHbeOI^$a3#s<@~3cCLX$-;~PA8nt^d_R+{tovb!Adt&>}4f3{oQ^2$W% zc|_HJb@q)~vbBFL-$br`7*cF`Ozz0uOD%!EkKNXm+zl!@-fp&Ga|44GultXn!{PZF zoAiI_EZp{Ed#e_6#j%g?pSsN6<{B7#Qf{NkmdKI;Vst08`R>5C8xG literal 0 HcmV?d00001 diff --git a/apps/browser/src/images/berry38.png b/apps/browser/src/images/berry38.png new file mode 100644 index 0000000000000000000000000000000000000000..44a670637010dc0a565f5d67efc796a85120dff2 GIT binary patch literal 1244 zcmV<21S9*2P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91CZGcV1ONa40RR91CIA2c0EF@&TL1tAMoC0LR9Fe^SZ{1oRTTfdx4Mem z6p^it$ow!Mgds+VZkS<&7>yWbB9yU2gBlYt86Wf$Ve-Xx>G5o2Djl1@R`JQnC0jx!x`_Laj{S}O;&>>XJdb3kNb2O)Ik;{0LTHI3M)sXX$bS~G zfsh1vmU`j!-G#cRny|WUDNfc0(DK$*NiO>lOjAszfUZx*O09q^|FWyr}8!^Pvhhj^NfP*I@`1V|kw*?97rrEbdX6mQS?<4ke(9)kaVwhF> z(04{+jJEZSQT#eAKaeehn+@BVT(YKu`+96irTs`Q_(6~Kp|2xik*gXuc8y@L0CIOW z86O<1DW?+p*ubv1yT%zY2EG_h;jvxAIPpvF*3O*RJGTPwba>&ZtCs3?p=V;O?$cP$ zf@T81a>)tNc4C?<^L|l-M*c?H|LI-J&MO zvrmR)^=^3FMs*}cjj(J9P9=?v1tU763^{eo#C_d+1#7lnz`IAoNT#ySRE~!Sj5fnQ zy0_+A{E__Ov;YV=4sc3EX`wbk { + let service: AtRiskCipherBadgeUpdaterService; + + let setState: jest.Mock; + let clearState: jest.Mock; + let warning: jest.Mock; + let getAllDecryptedForUrl: jest.Mock; + let getTab: jest.Mock; + let addListener: jest.Mock; + + const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + const cipherViews$ = new BehaviorSubject([]); + const pendingTasks$ = new BehaviorSubject([]); + const userId = "test-user-id" as UserId; + + beforeEach(async () => { + setState = jest.fn().mockResolvedValue(undefined); + clearState = jest.fn().mockResolvedValue(undefined); + warning = jest.fn(); + getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); + getTab = jest.fn(); + addListener = jest.fn(); + + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); + jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); + + service = new AtRiskCipherBadgeUpdaterService( + { setState, clearState } as unknown as BadgeService, + { activeAccount$ } as unknown as AccountService, + { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { warning } as unknown as LogService, + { pendingTasks$ } as unknown as TaskService, + ); + + await service.init(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("clears the tab state when there are no ciphers and no pending tasks", async () => { + const tab = { id: 1 } as chrome.tabs.Tab; + + await service["setTabState"](tab, userId, []); + + expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + }); + + it("sets state when there are pending tasks for the tab", async () => { + const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "at-risk-cipher-badge-3", + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + text: Unset, + backgroundColor: Unset, + }, + 3, + ); + }); +}); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts new file mode 100644 index 00000000000..47364958ad8 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -0,0 +1,163 @@ +import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; + +export class AtRiskCipherBadgeUpdaterService { + private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); + private tabUpdated$ = new Subject(); + private tabRemoved$ = new Subject(); + private tabActivated$ = new Subject(); + + private activeUserData$ = this.accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + of(user.id), + this.taskService + .pendingTasks$(user.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()), + ]), + ), + ); + + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private cipherService: CipherService, + private logService: LogService, + private taskService: TaskService, + ) { + combineLatest({ + replaced: this.tabReplaced$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { + await this.clearTabState(replaced.removedTabId); + await this.setTabState(replaced.addedTab, userId, pendingTasks); + }), + ) + .subscribe(() => {}); + + combineLatest({ + tab: this.tabActivated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + combineLatest({ + tab: this.tabUpdated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + this.tabRemoved$ + .pipe( + mergeMap(async (tabId) => { + await this.clearTabState(tabId); + }), + ) + .subscribe(); + } + + init() { + BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { + const newTab = await BrowserApi.getTab(addedTabId); + if (!newTab) { + this.logService.warning( + `Tab replaced event received but new tab not found (id: ${addedTabId})`, + ); + return; + } + + this.tabReplaced$.next({ + removedTabId, + addedTab: newTab, + }); + }); + + BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { + if (changeInfo.url) { + this.tabUpdated$.next(tab); + } + }); + + BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { + const tab = await BrowserApi.getTab(activeInfo.tabId); + if (!tab) { + this.logService.warning( + `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, + ); + return; + } + + this.tabActivated$.next(tab); + }); + + BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); + } + + /** Sets the pending task state for the tab */ + private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { + if (!tab.id) { + this.logService.warning("Tab event received but tab id is undefined"); + return; + } + + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; + + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + await this.clearTabState(tab.id); + return; + } + + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + tab.id, + ); + } + + /** Clears the pending task state from a tab */ + private async clearTabState(tabId: number) { + await this.badgeService.clearState(StateName(tabId)); + } +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2f4fcf0ef51..7eb2d4b0656 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract filterCiphersForUrl( ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, + /** When true, will override the match strategy for the cipher if it is Never. */ + overrideNeverMatchStrategy?: true, ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index 155d3d59f7c..aae9438df2e 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -111,6 +111,33 @@ describe("LoginUriView", () => { expect(actual).toBe(false); }); + + it("overrides Never match strategy with Domain when parameter is set", () => { + const loginUri = new LoginUriView(); + loginUri.uri = "https://example.org"; + loginUri.match = UriMatchStrategy.Never; + + expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true); + expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false); + }); + + it("overrides Never match strategy when passed in as default strategy", () => { + const loginUriNoMatch = new LoginUriView(); + loginUriNoMatch.uri = "https://example.org"; + + expect( + loginUriNoMatch.matchesUri( + "https://example.org", + new Set(), + UriMatchStrategy.Never, + true, + ), + ).toBe(true); + + expect( + loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never), + ).toBe(false); + }); }); describe("using host matching", () => { diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 38cd517e542..49ac9c6278f 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -142,6 +142,8 @@ export class LoginUriView implements View { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (!this.uri || !targetUri) { return false; @@ -150,6 +152,12 @@ export class LoginUriView implements View { let matchType = this.match ?? defaultUriMatch; matchType ??= UriMatchStrategy.Domain; + // Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true. + // This is useful in scenarios when the cipher should be matched to rely other information other than autofill. + if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) { + matchType = UriMatchStrategy.Domain; + } + const targetDomain = Utils.getDomain(targetUri); const matchDomains = equivalentDomains.add(targetDomain); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index d268cf4afaa..44c6ee8f2e9 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -82,12 +82,16 @@ export class LoginView extends ItemView { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (this.uris == null) { return false; } - return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch)); + return this.uris.some((uri) => + uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), + ); } static fromJSON(obj: Partial>): LoginView { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d89a41aba1f..f6e12e71edd 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { return await firstValueFrom( this.cipherViews$(userId).pipe( @@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction { url, includeOtherTypes, defaultMatch, + overrideNeverMatchStrategy, ), ), ), @@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction { url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { if (url == null && includeOtherTypes == null) { return []; @@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction { } if (cipherIsLogin) { - return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri( + cipher, + url, + equivalentDomains, + defaultMatch, + overrideNeverMatchStrategy, + ); } return false; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 1c7a4382a04..5ef1d9bdc75 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -174,13 +174,19 @@ export class CipherViewLikeUtils { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + overrideNeverMatchStrategy?: true, ): boolean => { if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { return false; } if (!this.isCipherListView(cipher)) { - return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + return cipher.login.matchesUri( + targetUri, + equivalentDomains, + defaultUriMatch, + overrideNeverMatchStrategy, + ); } const login = this.getLogin(cipher); @@ -198,7 +204,7 @@ export class CipherViewLikeUtils { }); return loginUriViews.some((uriView) => - uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), ); }; From bcd7eb746a1efda98e499f78191ab4caf63051f2 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:58:49 -0400 Subject: [PATCH 08/25] [BRE-1006] Removing unused workflow: release-desktop-beta (#16263) --- .github/workflows/release-desktop-beta.yml | 1104 -------------------- 1 file changed, 1104 deletions(-) delete mode 100644 .github/workflows/release-desktop-beta.yml diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml deleted file mode 100644 index eb6af20f9ee..00000000000 --- a/.github/workflows/release-desktop-beta.yml +++ /dev/null @@ -1,1104 +0,0 @@ -name: Release Desktop Beta - -on: - workflow_dispatch: - inputs: - version_number: - description: "New Beta Version" - required: true - -defaults: - run: - shell: bash - -jobs: - setup: - name: Setup - runs-on: ubuntu-22.04 - permissions: - contents: write - outputs: - release_version: ${{ steps.version.outputs.version }} - release_channel: ${{ steps.release_channel.outputs.channel }} - branch_name: ${{ steps.branch.outputs.branch_name }} - build_number: ${{ steps.increment-version.outputs.build_number }} - node_version: ${{ steps.retrieve-node-version.outputs.node_version }} - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Branch check - run: | - if [[ "$GITHUB_REF" != "refs/heads/main" ]] && [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then - echo "===================================" - echo "[!] Can only release from the 'main', 'rc' or 'hotfix-rc' branches" - echo "===================================" - exit 1 - fi - - - name: Bump Desktop Version - Root - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version --workspace=@bitwarden/desktop ${VERSION}-beta - - - name: Bump Desktop Version - App - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version ${VERSION}-beta - working-directory: "apps/desktop/src" - - - name: Check Release Version - id: version - uses: bitwarden/gh-actions/release-version-check@main - with: - release-type: 'Initial Release' - project-type: ts - file: apps/desktop/src/package.json - monorepo: true - monorepo-project: desktop - - - name: Increment Version - id: increment-version - run: | - BUILD_NUMBER=$(expr 3000 + $GITHUB_RUN_NUMBER) - echo "Setting build number to $BUILD_NUMBER" - echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT - - - name: Get Version Channel - id: release_channel - run: | - case "${{ steps.version.outputs.version }}" in - *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT - echo "[!] We do not yet support 'alpha'" - exit 1 - ;; - *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT - ;; - *) - echo "channel=latest" >> $GITHUB_OUTPUT - ;; - esac - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - - name: Create desktop-beta-release branch - id: branch - env: - VERSION: ${{ github.event.inputs.version_number }} - run: | - find="." - replace="_" - ver=${VERSION//$find/$replace} - branch_name=desktop-beta-release-$ver-beta - - git switch -c $branch_name - git add . - git commit -m "Bump desktop version to $VERSION-beta" - - git push -u origin $branch_name - - echo "branch_name=$branch_name" >> $GITHUB_OUTPUT - - - name: Get Node Version - id: retrieve-node-version - run: | - NODE_NVMRC=$(cat .nvmrc) - NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - - linux: - name: Linux Build - runs-on: ubuntu-22.04 - needs: setup - permissions: - contents: read - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up environment - run: | - sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm - - - name: Set up Snap - run: sudo snap install snapcraft --classic - - - name: Print environment - run: | - node --version - npm --version - snap --version - snapcraft --version || echo 'snapcraft unavailable' - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application - run: npm run dist:lin - - - name: Upload .deb artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb - if-no-files-found: error - - - name: Upload .rpm artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm - if-no-files-found: error - - - name: Upload .freebsd artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - if-no-files-found: error - - - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap - path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap - if-no-files-found: error - - - name: Upload .AppImage artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}-linux.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml - if-no-files-found: error - - - windows: - name: Windows Build - runs-on: windows-2022 - needs: setup - permissions: - contents: read - id-token: write - defaults: - run: - shell: pwsh - working-directory: apps/desktop - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Install AST - run: dotnet tool install --global AzureSignTool --version 4.0.1 - - - name: Set up environment - run: choco install checksum --no-progress - - - name: Print environment - run: | - node --version - npm --version - choco --version - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "code-signing-vault-url, - code-signing-client-id, - code-signing-tenant-id, - code-signing-client-secret, - code-signing-cert-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build & Sign (dev) - env: - ELECTRON_BUILDER_SIGN: 1 - SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }} - SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }} - SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }} - SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }} - SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }} - run: | - npm run build - npm run pack:win - - - name: Rename appx files for store - run: | - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx" - - - name: Package for Chocolatey - run: | - Copy-Item -Path ./stores/chocolatey -Destination ./dist/chocolatey -Recurse - Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe ` - -Destination ./dist/chocolatey - - $checksum = checksum -t sha256 ./dist/chocolatey/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - $chocoInstall = "./dist/chocolatey/tools/chocolateyinstall.ps1" - (Get-Content $chocoInstall).replace('__version__', "$env:_PACKAGE_VERSION").replace('__checksum__', $checksum) | Set-Content $chocoInstall - choco pack ./dist/chocolatey/bitwarden.nuspec --version "$env:_PACKAGE_VERSION" --out ./dist/chocolatey - - - name: Fix NSIS artifact names for auto-updater - run: | - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - - - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - if-no-files-found: error - - - name: Upload installer exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - if-no-files-found: error - - - name: Upload appx ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - if-no-files-found: error - - - name: Upload store appx ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - if-no-files-found: error - - - name: Upload NSIS ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - if-no-files-found: error - - - name: Upload appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - if-no-files-found: error - - - name: Upload store appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - if-no-files-found: error - - - name: Upload NSIS x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - if-no-files-found: error - - - name: Upload appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - if-no-files-found: error - - - name: Upload store appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - if-no-files-found: error - - - name: Upload NSIS ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - if-no-files-found: error - - - name: Upload nupkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}.yml - path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml - if-no-files-found: error - - - macos-build: - name: MacOS Build - runs-on: macos-13 - needs: setup - permissions: - contents: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Cache Build - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Cache Safari - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application (dev) - run: npm run build - - - macos-package-github: - name: MacOS Package GitHub Release Assets - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifacts from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for .dmg - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/dmg/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application (dist) - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - run: npm run pack:mac - - - name: Upload .zip artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip - if-no-files-found: error - - - name: Upload .dmg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg - if-no-files-found: error - - - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}-mac.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml - if-no-files-found: error - - - macos-package-mas: - name: MacOS Package Prod Release Asset - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for App Store - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/mas/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application for App Store - run: npm run pack:mac:mas - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - - - name: Upload .pkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - if-no-files-found: error - - release: - name: Release beta channel to S3 - runs-on: ubuntu-22.04 - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - permissions: - contents: read - id-token: write - deployments: write - steps: - - name: Create GitHub deployment - uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 - id: deployment - with: - token: '${{ secrets.GITHUB_TOKEN }}' - initial-status: 'in_progress' - environment: 'Desktop - Beta' - description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}' - task: release - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "aws-electron-access-id, - aws-electron-access-key, - aws-electron-bucket-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Download all artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - path: apps/desktop/artifacts - - - name: Rename .pkg to .pkg.archive - env: - PKG_VERSION: ${{ needs.setup.outputs.release_version }} - working-directory: apps/desktop/artifacts - run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive - - - name: Publish artifacts to S3 - env: - AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} - AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} - AWS_DEFAULT_REGION: 'us-west-2' - AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} - working-directory: apps/desktop/artifacts - run: | - aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ - --acl "public-read" \ - --recursive \ - --quiet - - - name: Update deployment status to Success - if: ${{ success() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'success' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - - name: Update deployment status to Failure - if: ${{ failure() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'failure' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - remove-branch: - name: Remove branch - runs-on: ubuntu-22.04 - if: always() - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - - release - permissions: - contents: write - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - name: Remove branch - env: - BRANCH: ${{ needs.setup.outputs.branch_name }} - run: git push origin --delete $BRANCH From 73e8532eccdfc101898b4cfcee2a5c878667e668 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:09:02 -0400 Subject: [PATCH 09/25] feat(tokens): Allow Inactive user authenticated API calls --- .../browser/src/background/main.background.ts | 3 +- .../src/platform/services/node-api.service.ts | 3 + .../service-container/service-container.ts | 3 +- .../src/services/jslib-services.module.ts | 3 +- libs/common/src/abstractions/api.service.ts | 27 +- .../src/auth/abstractions/token.service.ts | 18 +- .../src/auth/services/token.service.spec.ts | 290 ++---------------- .../common/src/auth/services/token.service.ts | 32 +- .../vault-timeout-settings.service.ts | 10 +- .../internal/signalr-connection.service.ts | 2 +- .../web-push-notifications-api.service.ts | 6 +- .../worker-webpush-connection.service.ts | 2 +- .../services/config/config-api.service.ts | 15 +- libs/common/src/services/api.service.spec.ts | 218 ++++++++++++- libs/common/src/services/api.service.ts | 136 +++++--- 15 files changed, 406 insertions(+), 362 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 75481dde8cf..35704fc289f 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -737,6 +737,7 @@ export default class MainBackground { this.logService, (logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId), this.vaultTimeoutSettingsService, + this.accountService, { createRequest: (url, request) => new Request(url, request) }, ); @@ -843,7 +844,7 @@ export default class MainBackground { this.tokenService, ); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.configService = new DefaultConfigService( this.configApiService, diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index d695272364b..e6527ed3abd 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -4,6 +4,7 @@ import * as FormData from "form-data"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as fe from "node-fetch"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -28,6 +29,7 @@ export class NodeApiService extends ApiService { logService: LogService, logoutCallback: () => Promise, vaultTimeoutSettingsService: VaultTimeoutSettingsService, + accountService: AccountService, customUserAgent: string = null, ) { super( @@ -39,6 +41,7 @@ export class NodeApiService extends ApiService { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, { createRequest: (url, request) => new Request(url, request) }, customUserAgent, ); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 508ade4650e..27fde5863de 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -504,12 +504,13 @@ export class ServiceContainer { this.logService, logoutCallback, this.vaultTimeoutSettingsService, + this.accountService, customUserAgent, ); this.containerService = new ContainerService(this.keyService, this.encryptService); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.authService = new AuthService( this.accountService, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c6f8d4a3ae9..72bdd9f8b2f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -752,6 +752,7 @@ const safeProviders: SafeProvider[] = [ LogService, LOGOUT_CALLBACK, VaultTimeoutSettingsService, + AccountService, HTTP_OPERATIONS, ], }), @@ -1158,7 +1159,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: ConfigApiServiceAbstraction, useClass: ConfigApiService, - deps: [ApiServiceAbstraction, TokenServiceAbstraction], + deps: [ApiServiceAbstraction], }), safeProvider({ provide: AnonymousHubServiceAbstraction, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 726b04534ad..ab217c56fc4 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -127,11 +127,34 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher * of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service. */ export abstract class ApiService { + /** @deprecated Use the overload accepting the user you want the request authenticated for. */ abstract send( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authed: true, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an unauthenticated API request. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + authed: false, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an API request authenticated with the given users ID. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + userId: UserId, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, @@ -499,7 +522,7 @@ export abstract class ApiService { abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise; abstract postSetupPayment(): Promise; - abstract getActiveBearerToken(): Promise; + abstract getActiveBearerToken(userId: UserId): Promise; abstract fetch(request: Request): Promise; abstract nativeFetch(request: Request): Promise; diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 2139f32fca2..673bc7bdf0a 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -72,14 +72,14 @@ export abstract class TokenService { * @param userId - The optional user id to get the access token for; if not provided, the active user is used. * @returns A promise that resolves with the access token or null. */ - abstract getAccessToken(userId?: UserId): Promise; + abstract getAccessToken(userId: UserId): Promise; /** * Gets the refresh token. * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. * @returns A promise that resolves with the refresh token or null. */ - abstract getRefreshToken(userId?: UserId): Promise; + abstract getRefreshToken(userId: UserId): Promise; /** * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -96,10 +96,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client ID for the active user. + * Gets the API Key Client ID for the given user. * @returns A promise that resolves with the API Key Client ID or undefined */ - abstract getClientId(userId?: UserId): Promise; + abstract getClientId(userId: UserId): Promise; /** * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -116,10 +116,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client Secret for the active user. + * Gets the API Key Client Secret for the given user. * @returns A promise that resolves with the API Key Client Secret or undefined */ - abstract getClientSecret(userId?: UserId): Promise; + abstract getClientSecret(userId: UserId): Promise; /** * Sets the two factor token for the given email in global state. @@ -157,7 +157,7 @@ export abstract class TokenService { * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration * @returns A promise that resolves with the expiration date for the access token. */ - abstract getTokenExpirationDate(): Promise; + abstract getTokenExpirationDate(userId: UserId): Promise; /** * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. @@ -168,14 +168,14 @@ export abstract class TokenService { * based on the actual expiration. * @returns {Promise} Promise resolving to the adjusted seconds remaining. */ - abstract tokenSecondsRemaining(offsetSeconds?: number): Promise; + abstract tokenSecondsRemaining(userId: UserId, offsetSeconds?: number): Promise; /** * Checks if the access token needs to be refreshed. * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. */ - abstract tokenNeedsRefresh(minutes?: number): Promise; + abstract tokenNeedsRefresh(userId: UserId, minutes?: number): Promise; /** * Gets the user id for the active user from the access token. diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 7274954c950..f4e4ec5e204 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -409,28 +409,8 @@ describe("TokenService", () => { }); describe("getAccessToken", () => { - it("returns null when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - - it("returns null when no access token is found in memory, disk, or secure storage", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - test.each([ - ["gets the access token from memory when a user id is provided ", userIdFromAccessToken], - ["gets the access token from memory when no user id is provided", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from memory when a user id is provided ", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -442,12 +422,10 @@ describe("TokenService", () => { .nextState(undefined); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -455,10 +433,7 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - test.each([ - ["gets the access token from disk when the user id is specified", userIdFromAccessToken], - ["gets the access token from disk when no user id is specified", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from disk when the user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -469,12 +444,10 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); }); @@ -486,16 +459,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - test.each([ - [ - "gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", - userIdFromAccessToken, - ], - [ - "gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -509,27 +473,17 @@ describe("TokenService", () => { encryptService.decryptString.mockResolvedValue("decryptedAccessToken"); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual("decryptedAccessToken"); }); - test.each([ - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", - userIdFromAccessToken, - ], - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -540,14 +494,12 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // No access token key set // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -738,7 +690,7 @@ describe("TokenService", () => { // Act // note: don't await here because we want to test the error - const result = tokenService.getTokenExpirationDate(); + const result = tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); @@ -748,7 +700,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -763,7 +715,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithoutExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -777,7 +729,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithNonNumericExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -788,7 +740,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000)); @@ -801,7 +753,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(0); @@ -823,7 +775,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -849,7 +801,10 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(offsetSeconds); + const result = await tokenService.tokenSecondsRemaining( + userIdFromAccessToken, + offsetSeconds, + ); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -866,7 +821,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(true); @@ -878,7 +833,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(false); @@ -890,7 +845,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(2); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 2); // Assert expect(result).toEqual(true); @@ -902,7 +857,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(5); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 5); // Assert expect(result).toEqual(false); @@ -1565,26 +1520,6 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(refreshToken); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1603,25 +1538,6 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("gets the refresh token from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1645,27 +1561,6 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("gets the refresh token from secure storage when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - secureStorageService.get.mockResolvedValue(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from secure storage when a user id is specified", async () => { // Arrange @@ -1705,29 +1600,6 @@ describe("TokenService", () => { expect(secureStorageService.get).not.toHaveBeenCalled(); }); - it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - - // assert that secure storage was not called - expect(secureStorageService.get).not.toHaveBeenCalled(); - }); - it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => { // Arrange secureStorageService.get.mockResolvedValue(null); @@ -1944,45 +1816,7 @@ describe("TokenService", () => { }); describe("getClientId", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeUndefined(); - }); - - it("returns null when no client id is found in memory or disk", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - it("gets the client id from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(clientId); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from memory when given a user id", async () => { // Arrange singleUserStateProvider @@ -2002,25 +1836,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client id from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(clientId); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2215,45 +2030,17 @@ describe("TokenService", () => { }); describe("getClientSecret", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toBeUndefined(); - }); - it("returns null when no client secret is found in memory or disk", async () => { // Arrange globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getClientSecret(); + const result = await tokenService.getClientSecret(userIdFromAccessToken); // Assert expect(result).toBeNull(); }); describe("Memory storage tests", () => { - it("gets the client secret from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(clientSecret); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2273,25 +2060,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client secret from disk when no user id specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(clientSecret); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from disk when a user id is specified", async () => { // Arrange singleUserStateProvider diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 21ccd672056..0721927bd13 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -452,9 +452,7 @@ export class TokenService implements TokenServiceAbstraction { await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getAccessToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getAccessToken(userId: UserId): Promise { if (!userId) { return null; } @@ -631,9 +629,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getRefreshToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getRefreshToken(userId: UserId): Promise { if (!userId) { return null; } @@ -746,9 +742,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientId(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientId(userId: UserId): Promise { if (!userId) { return undefined; } @@ -822,9 +816,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientSecret(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientSecret(userId: UserId): Promise { if (!userId) { return undefined; } @@ -915,7 +907,9 @@ export class TokenService implements TokenServiceAbstraction { if (Utils.isGuid(tokenOrUserId)) { token = await this.getAccessToken(tokenOrUserId as UserId); } else { - token ??= await this.getAccessToken(); + token ??= await this.getAccessToken( + await firstValueFrom(this.activeUserIdGlobalState.state$), + ); } if (token == null) { @@ -928,10 +922,10 @@ export class TokenService implements TokenServiceAbstraction { // TODO: PM-6678- tech debt - consider consolidating the return types of all these access // token data retrieval methods to return null if something goes wrong instead of throwing an error. - async getTokenExpirationDate(): Promise { + async getTokenExpirationDate(userId: UserId): Promise { let decoded: DecodedAccessToken; try { - decoded = await this.decodeAccessToken(); + decoded = await this.decodeAccessToken(userId); } catch (error) { throw new Error("Failed to decode access token: " + error.message); } @@ -947,8 +941,8 @@ export class TokenService implements TokenServiceAbstraction { return expirationDate; } - async tokenSecondsRemaining(offsetSeconds = 0): Promise { - const date = await this.getTokenExpirationDate(); + async tokenSecondsRemaining(userId: UserId, offsetSeconds = 0): Promise { + const date = await this.getTokenExpirationDate(userId); if (date == null) { return 0; } @@ -957,8 +951,8 @@ export class TokenService implements TokenServiceAbstraction { return Math.round(msRemaining / 1000); } - async tokenNeedsRefresh(minutes = 5): Promise { - const sRemaining = await this.tokenSecondsRemaining(); + async tokenNeedsRefresh(userId: UserId, minutes = 5): Promise { + const sRemaining = await this.tokenSecondsRemaining(userId); return sRemaining < 60 * minutes; } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 7e43ee394f6..e40b896dc8c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -70,17 +70,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA // We swap these tokens from being on disk for lock actions, and in memory for logout actions // Get them here to set them to their new location after changing the timeout action and clearing if needed - const accessToken = await this.tokenService.getAccessToken(); - const refreshToken = await this.tokenService.getRefreshToken(); - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const accessToken = await this.tokenService.getAccessToken(userId); + const refreshToken = await this.tokenService.getRefreshToken(userId); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); await this.setVaultTimeout(userId, timeout); if (timeout != VaultTimeoutStringType.Never && action === VaultTimeoutAction.LogOut) { // if we have a vault timeout and the action is log out, reset tokens // as the tokens were stored on disk and now should be stored in memory - await this.tokenService.clearTokens(); + await this.tokenService.clearTokens(userId); } await this.setVaultTimeoutAction(userId, action); diff --git a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts index 58d6311c668..5998668f138 100644 --- a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts @@ -78,7 +78,7 @@ export class SignalRConnectionService { return new Observable((subsciber) => { const connection = this.hubConnectionBuilderFactory() .withUrl(notificationsUrl + "/hub", { - accessTokenFactory: () => this.apiService.getActiveBearerToken(), + accessTokenFactory: () => this.apiService.getActiveBearerToken(userId), skipNegotiation: true, transport: HttpTransportType.WebSockets, }) diff --git a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts index 891dab2c069..861835c086d 100644 --- a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts +++ b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "@bitwarden/user-core"; + import { ApiService } from "../../../abstractions/api.service"; import { AppIdService } from "../../abstractions/app-id.service"; @@ -12,13 +14,13 @@ export class WebPushNotificationsApiService { /** * Posts a device-user association to the server and ensures it's installed for push server notifications */ - async putSubscription(pushSubscription: PushSubscriptionJSON): Promise { + async putSubscription(pushSubscription: PushSubscriptionJSON, userId: UserId): Promise { const request = WebPushRequest.from(pushSubscription); await this.apiService.send( "POST", `/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`, request, - true, + userId, false, ); } diff --git a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts index d8a2c33568e..8b38ebd5b17 100644 --- a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts @@ -143,7 +143,7 @@ class MyWebPushConnector implements WebPushConnector { await subscriptionUsersState.update(() => subscriptionUsers); // Inform the server about the new subscription-user association - await this.webPushApiService.putSubscription(subscription.toJSON()); + await this.webPushApiService.putSubscription(subscription.toJSON(), this.userId); }), switchMap(() => this.pushEvent$), map((e) => { diff --git a/libs/common/src/platform/services/config/config-api.service.ts b/libs/common/src/platform/services/config/config-api.service.ts index b7ecb9c8712..752a0075346 100644 --- a/libs/common/src/platform/services/config/config-api.service.ts +++ b/libs/common/src/platform/services/config/config-api.service.ts @@ -1,22 +1,21 @@ import { ApiService } from "../../../abstractions/api.service"; -import { TokenService } from "../../../auth/abstractions/token.service"; import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ServerConfigResponse } from "../../models/response/server-config.response"; export class ConfigApiService implements ConfigApiServiceAbstraction { - constructor( - private apiService: ApiService, - private tokenService: TokenService, - ) {} + constructor(private apiService: ApiService) {} async get(userId: UserId | null): Promise { // Authentication adds extra context to config responses, if the user has an access token, we want to use it // We don't particularly care about ensuring the token is valid and not expired, just that it exists - const authed: boolean = - userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null; + let r: any; + if (userId == null) { + r = await this.apiService.send("GET", "/config", null, false, true); + } else { + r = await this.apiService.send("GET", "/config", null, userId, true); + } - const r = await this.apiService.send("GET", "/config", null, authed, true); return new ServerConfigResponse(r); } } diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index fffe0478254..144b0cc02c4 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -1,13 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { ObservedValueOf, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/user-core"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceType } from "../enums"; -import { VaultTimeoutSettingsService } from "../key-management/vault-timeout"; +import { + VaultTimeoutAction, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "../key-management/vault-timeout"; import { ErrorResponse } from "../models/response/error.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; @@ -25,10 +31,14 @@ describe("ApiService", () => { let logService: MockProxy; let logoutCallback: jest.Mock, [reason: LogoutReason]>; let vaultTimeoutSettingsService: MockProxy; + let accountService: MockProxy; let httpOperations: MockProxy; let sut: ApiService; + const testActiveUser = "activeUser" as UserId; + const testInactiveUser = "inactiveUser" as UserId; + beforeEach(() => { tokenService = mock(); platformUtilsService = mock(); @@ -40,6 +50,15 @@ describe("ApiService", () => { logService = mock(); logoutCallback = jest.fn(); vaultTimeoutSettingsService = mock(); + accountService = mock(); + + accountService.activeAccount$ = of({ + id: testActiveUser, + email: "user1@example.com", + emailVerified: true, + name: "Test Name", + } satisfies ObservedValueOf); + httpOperations = mock(); sut = new ApiService( @@ -51,6 +70,7 @@ describe("ApiService", () => { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, httpOperations, "custom-user-agent", ); @@ -62,6 +82,12 @@ describe("ApiService", () => { getApiUrl: () => "https://example.com", } satisfies Partial as Environment); + environmentService.getEnvironment$.mockReturnValue( + of({ + getApiUrl: () => "https://authed.example.com", + } satisfies Partial as Environment), + ); + httpOperations.createRequest.mockImplementation((url, request) => { return { url: url, @@ -96,6 +122,7 @@ describe("ApiService", () => { expect(nativeFetch).toHaveBeenCalledTimes(1); const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://authed.example.com/something"); // This should get set for users of send expect(request.cache).toBe("no-store"); // TODO: Could expect on the credentials parameter @@ -109,6 +136,185 @@ describe("ApiService", () => { // The response body expect(response).toEqual({ hello: "world" }); }); + + it("authenticates with non-active user when user is passed in", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce( + of({ + getApiUrl: () => "https://inactive.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken + .calledWith(testInactiveUser) + .mockResolvedValue("inactive_access_token"); + + tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + const response = await sut.send( + "GET", + "/something", + null, + testInactiveUser, + true, + null, + null, + ); + + expect(nativeFetch).toHaveBeenCalledTimes(1); + const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://inactive.example.com/something"); + // This should get set for users of send + expect(request.cache).toBe("no-store"); + // TODO: Could expect on the credentials parameter + expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension + // Custom user agent should get set + expect(request.headers.get("User-Agent")).toBe("custom-user-agent"); + // This should be set when the caller has indicated there is a response + expect(request.headers.get("Accept")).toBe("application/json"); + // If they have indicated that it's authed, then the authorization header should get set. + expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token"); + // The response body + expect(response).toEqual({ hello: "world" }); + }); + + const cases: { + name: string; + authedOrUserId: boolean | UserId; + expectedEffectiveUser: UserId; + }[] = [ + { + name: "refreshes active user when true passed in for auth", + authedOrUserId: true, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes acess token when the user passed in happens to be the active one", + authedOrUserId: testActiveUser, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes access token when the user passed in happens to be inactive", + authedOrUserId: testInactiveUser, + expectedEffectiveUser: testInactiveUser, + }, + ]; + + it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => { + environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue( + of({ + getApiUrl: () => `https://${expectedEffectiveUser}.example.com`, + getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`, + } satisfies Partial as Environment), + ); + + tokenService.getAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_access_token`); + + tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true); + + tokenService.getRefreshToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_refresh_token`); + + tokenService.decodeAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith(`${expectedEffectiveUser}_new_access_token`) + .mockResolvedValue({ sub: expectedEffectiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + `${expectedEffectiveUser}_new_access_token`, + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + `${expectedEffectiveUser}_new_refresh_token`, + ) + .mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` }); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + if (request.url.includes("identity")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: `${expectedEffectiveUser}_new_access_token`, + refresh_token: `${expectedEffectiveUser}_new_refresh_token`, + }), + } satisfies Partial as Response); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await sut.send("GET", "/something", null, authedOrUserId, true, null, null); + + expect(nativeFetch).toHaveBeenCalledTimes(2); + }); }); const errorData: { @@ -169,9 +375,11 @@ describe("ApiService", () => { it.each(errorData)( "throws error-like response when not ok response with $name", async ({ input, error }) => { - environmentService.environment$ = of({ - getApiUrl: () => "https://example.com", - } satisfies Partial as Environment); + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment), + ); httpOperations.createRequest.mockImplementation((url, request) => { return { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 6a670368b1f..bbf990122df 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -47,6 +47,7 @@ import { ProviderUserUserDetailsResponse, } from "../admin-console/models/response/provider/provider-user.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; @@ -121,7 +122,7 @@ import { ListResponse } from "../models/response/list.response"; import { ProfileResponse } from "../models/response/profile.response"; import { UserKeyResponse } from "../models/response/user-key.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; -import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; import { flagEnabled } from "../platform/misc/flags"; @@ -155,7 +156,7 @@ export type HttpOperations = { export class ApiService implements ApiServiceAbstraction { private device: DeviceType; private deviceType: string; - private refreshTokenPromise: Promise | undefined; + private refreshTokenPromise: Record> = {}; /** * The message (responseJson.ErrorModel.Message) that comes back from the server when a new device verification is required. @@ -172,6 +173,7 @@ export class ApiService implements ApiServiceAbstraction { private logService: LogService, private logoutCallback: (logoutReason: LogoutReason) => Promise, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private readonly accountService: AccountService, private readonly httpOperations: HttpOperations, private customUserAgent: string = null, ) { @@ -209,7 +211,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify(identityToken), - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), cache: "no-store", headers: headers, method: "POST", @@ -241,9 +243,13 @@ export class ApiService implements ApiServiceAbstraction { return Promise.reject(new ErrorResponse(responseJson, response.status, true)); } - async refreshIdentityToken(): Promise { + async refreshIdentityToken(userId: UserId | null = null): Promise { + const normalizedUser = (userId ??= await this.getActiveUser()); + if (normalizedUser == null) { + throw new Error("No user provided and no active user, cannot refresh the identity token."); + } try { - await this.refreshToken(); + await this.refreshToken(normalizedUser); } catch (e) { this.logService.error("Error refreshing access token: ", e); throw e; @@ -1398,11 +1404,16 @@ export class ApiService implements ApiServiceAbstraction { if (this.customUserAgent != null) { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); const response = await this.fetch( this.httpOperations.createRequest(env.getEventsUrl() + "/collect", { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: "POST", body: JSON.stringify(request), headers: headers, @@ -1444,7 +1455,11 @@ export class ApiService implements ApiServiceAbstraction { async getMasterKeyFromKeyConnector( keyConnectorUrl: string, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot get master key from key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1469,7 +1484,11 @@ export class ApiService implements ApiServiceAbstraction { keyConnectorUrl: string, request: KeyConnectorUserKeyRequest, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot post key to key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1521,10 +1540,10 @@ export class ApiService implements ApiServiceAbstraction { // Helpers - async getActiveBearerToken(): Promise { - let accessToken = await this.tokenService.getAccessToken(); - if (await this.tokenService.tokenNeedsRefresh()) { - accessToken = await this.refreshToken(); + async getActiveBearerToken(userId: UserId): Promise { + let accessToken = await this.tokenService.getAccessToken(userId); + if (await this.tokenService.tokenNeedsRefresh(userId)) { + accessToken = await this.refreshToken(userId); } return accessToken; } @@ -1563,7 +1582,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + path, { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "GET", }), @@ -1646,26 +1665,27 @@ export class ApiService implements ApiServiceAbstraction { } // Keep the running refreshTokenPromise to prevent parallel calls. - protected refreshToken(): Promise { - if (this.refreshTokenPromise === undefined) { - this.refreshTokenPromise = this.internalRefreshToken(); - void this.refreshTokenPromise.finally(() => { - this.refreshTokenPromise = undefined; + protected refreshToken(userId: UserId): Promise { + if (this.refreshTokenPromise[userId] === undefined) { + // TODO: Have different promise for each user + this.refreshTokenPromise[userId] = this.internalRefreshToken(userId); + void this.refreshTokenPromise[userId].finally(() => { + delete this.refreshTokenPromise[userId]; }); } - return this.refreshTokenPromise; + return this.refreshTokenPromise[userId]; } - private async internalRefreshToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async internalRefreshToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken != null && refreshToken !== "") { - return this.refreshAccessToken(); + return await this.refreshAccessToken(userId); } - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); if (!Utils.isNullOrWhitespace(clientId) && !Utils.isNullOrWhitespace(clientSecret)) { - return this.refreshApiToken(); + return await this.refreshApiToken(userId); } this.refreshAccessTokenErrorCallback(); @@ -1673,8 +1693,8 @@ export class ApiService implements ApiServiceAbstraction { throw new Error("Cannot refresh access token, no refresh token or api keys are stored."); } - protected async refreshAccessToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async refreshAccessToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken == null || refreshToken === "") { throw new Error(); } @@ -1687,8 +1707,8 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); - const decodedToken = await this.tokenService.decodeAccessToken(); + const env = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const decodedToken = await this.tokenService.decodeAccessToken(userId); const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify({ @@ -1697,7 +1717,7 @@ export class ApiService implements ApiServiceAbstraction { refresh_token: refreshToken, }), cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "POST", }), @@ -1732,9 +1752,9 @@ export class ApiService implements ApiServiceAbstraction { } } - protected async refreshApiToken(): Promise { - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + protected async refreshApiToken(userId: UserId): Promise { + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); const appId = await this.appIdService.getAppId(); const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); @@ -1751,7 +1771,12 @@ export class ApiService implements ApiServiceAbstraction { } const newDecodedAccessToken = await this.tokenService.decodeAccessToken(response.accessToken); - const userId = newDecodedAccessToken.sub; + + if (newDecodedAccessToken.sub !== userId) { + throw new Error( + `Token was supposed to be refreshed for ${userId} but the token we got back was for ${newDecodedAccessToken.sub}`, + ); + } const vaultTimeoutAction = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), @@ -1772,12 +1797,28 @@ export class ApiService implements ApiServiceAbstraction { method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authedOrUserId: UserId | boolean, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + if (authedOrUserId == null) { + throw new Error("A user id was given but it was null, cannot complete API request."); + } + + let userId: UserId | null = null; + if (typeof authedOrUserId === "boolean" && authedOrUserId) { + // Backwards compatible for authenticating the active user when `true` is passed in + userId = await this.getActiveUser(); + } else if (typeof authedOrUserId === "string") { + userId = authedOrUserId; + } + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl; // Prevent directory traversal from malicious paths @@ -1786,7 +1827,7 @@ export class ApiService implements ApiServiceAbstraction { apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); const [requestHeaders, requestBody] = await this.buildHeadersAndBody( - authed, + userId, hasResponse, body, alterHeaders, @@ -1794,7 +1835,7 @@ export class ApiService implements ApiServiceAbstraction { const requestInit: RequestInit = { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: method, }; requestInit.headers = requestHeaders; @@ -1810,13 +1851,13 @@ export class ApiService implements ApiServiceAbstraction { } else if (hasResponse && response.status === 200 && responseIsCsv) { return await response.text(); } else if (response.status !== 200 && response.status !== 204) { - const error = await this.handleError(response, false, authed); + const error = await this.handleError(response, false, userId != null); return Promise.reject(error); } } private async buildHeadersAndBody( - authed: boolean, + userToAuthenticate: UserId | null, hasResponse: boolean, body: any, alterHeaders: (headers: Headers) => void, @@ -1838,8 +1879,8 @@ export class ApiService implements ApiServiceAbstraction { if (alterHeaders != null) { alterHeaders(headers); } - if (authed) { - const authHeader = await this.getActiveBearerToken(); + if (userToAuthenticate != null) { + const authHeader = await this.getActiveBearerToken(userToAuthenticate); headers.set("Authorization", "Bearer " + authHeader); } else { // For unauthenticated requests, we need to tell the server what the device is for flag targeting, @@ -1901,8 +1942,11 @@ export class ApiService implements ApiServiceAbstraction { .join("&"); } - private async getCredentials(): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + private async getActiveUser(): Promise { + return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + } + + private async getCredentials(env: Environment): Promise { if (this.platformUtilsService.getClientType() !== ClientType.Web || env.hasBaseUrl()) { return "include"; } From 2de321d6e88b89f22a236b0a847b8d8d1fbe1a81 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 3 Sep 2025 09:34:35 -0700 Subject: [PATCH 10/25] [PM-24796] Ensure the CipherView prototype is not lost within the cache Signal (#16267) --- .../services/default-cipher-form-cache.service.spec.ts | 6 ++++-- .../services/default-cipher-form-cache.service.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts index c4fbfe7640d..4693c8ebf09 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts @@ -2,6 +2,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherFormCacheService } from "./default-cipher-form-cache.service"; @@ -36,9 +37,10 @@ describe("CipherFormCacheService", () => { it("updates the signal value", async () => { service = testBed.inject(CipherFormCacheService); - service.cacheCipherView({ id: "cipher-5" } as CipherView); + service.cacheCipherView(new CipherView({ id: "cipher-5" } as Cipher)); - expect(cacheSignal.set).toHaveBeenCalledWith({ id: "cipher-5" }); + expect(cacheSignal.set).toHaveBeenCalledWith(expect.any(CipherView)); // Ensure we keep the CipherView prototype + expect(cacheSignal.set).toHaveBeenCalledWith(expect.objectContaining({ id: "cipher-5" })); }); describe("initializedWithValue", () => { diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts index 73ec6549756..25581ae5ea1 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from "@angular/core"; +import { Jsonify } from "type-fest"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -32,10 +33,10 @@ export class CipherFormCacheService { * Update the cache with the new CipherView. */ cacheCipherView(cipherView: CipherView): void { - // Create a new shallow reference to force the signal to update + // Create a new reference to force the signal to update // By default, signals use `Object.is` to determine equality // Docs: https://angular.dev/guide/signals#signal-equality-functions - this.cipherCache.set({ ...cipherView } as CipherView); + this.cipherCache.set(CipherView.fromJSON(cipherView as Jsonify)); } /** From b6ef7716da99e5e3da345437d9bb95a2258da710 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 3 Sep 2025 10:56:55 -0700 Subject: [PATCH 11/25] [PM-24243] Load config service feature flag into SDK client (#15855) * [PM-24243] Load config service feature flag into SDK client during initialization * [PM-24243] Bump sdk version * [PM-24243] Update load_flags to use generic Map instead of strongly typed object * [PM-24243] Pass entire feature states object into SDK * [PM-24243] Bump SDK version * [PM-24243] Fix failing test --- .../browser/src/background/main.background.ts | 1 + .../service-container/service-container.ts | 1 + .../src/services/jslib-services.module.ts | 1 + .../services/sdk/default-sdk.service.spec.ts | 6 ++++++ .../services/sdk/default-sdk.service.ts | 21 ++++++++++++++++++- 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 35704fc289f..a14d43fd218 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -786,6 +786,7 @@ export default class MainBackground { this.kdfConfigService, this.keyService, this.stateProvider, + this.configService, ); this.passwordStrengthService = new PasswordStrengthService(); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 27fde5863de..e3359c17b91 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -602,6 +602,7 @@ export class ServiceContainer { this.kdfConfigService, this.keyService, this.stateProvider, + this.configService, customUserAgent, ); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 72bdd9f8b2f..4920acc1ba4 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1500,6 +1500,7 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, KeyService, StateProvider, + ConfigService, ], }), safeProvider({ diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 2b94305f0ec..2f6c32aa78d 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -41,6 +42,7 @@ describe("DefaultSdkService", () => { let platformUtilsService!: MockProxy; let kdfConfigService!: MockProxy; let keyService!: MockProxy; + let configService!: MockProxy; let service!: DefaultSdkService; let accountService!: FakeAccountService; let fakeStateProvider!: FakeStateProvider; @@ -56,6 +58,9 @@ describe("DefaultSdkService", () => { const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); fakeStateProvider = new FakeStateProvider(accountService); + configService = mock(); + + configService.serverConfig$ = new BehaviorSubject(null); // Can't use `of(mock())` for some reason environmentService.environment$ = new BehaviorSubject(mock()); @@ -68,6 +73,7 @@ describe("DefaultSdkService", () => { kdfConfigService, keyService, fakeStateProvider, + configService, ); }); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 9d965acb44b..210e1bdc59b 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -12,8 +12,10 @@ import { of, takeWhile, throwIfEmpty, + firstValueFrom, } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management"; @@ -67,7 +69,9 @@ export class DefaultSdkService implements SdkService { concatMap(async (env) => { await SdkLoadService.Ready; const settings = this.toSettings(env); - return await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings); + const client = await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings); + await this.loadFeatureFlags(client); + return client; }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -85,6 +89,7 @@ export class DefaultSdkService implements SdkService { private kdfConfigService: KdfConfigService, private keyService: KeyService, private stateProvider: StateProvider, + private configService: ConfigService, private userAgent: string | null = null, ) {} @@ -248,6 +253,20 @@ export class DefaultSdkService implements SdkService { // Initialize the SDK managed database and the client managed repositories. await initializeState(userId, client.platform().state(), this.stateProvider); + + await this.loadFeatureFlags(client); + } + + private async loadFeatureFlags(client: BitwardenClient) { + const serverConfig = await firstValueFrom(this.configService.serverConfig$); + + const featureFlagMap = new Map( + Object.entries(serverConfig?.featureStates ?? {}) + .filter(([, value]) => typeof value === "boolean") // The SDK only supports boolean feature flags at this time + .map(([key, value]) => [key, value] as [string, boolean]), + ); + + client.platform().load_flags(featureFlagMap); } private toSettings(env: Environment): ClientSettings { From 4027b78e20af1cb60f43316c18328359b46a9d0b Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:18:50 -0400 Subject: [PATCH 12/25] [PM-24101] Switching to use the orgKeys$ from the key service instead of getOrgKey (#15781) * Switching to use the orgKeys$ from the key service instead of getOrgKey * Using account service instead of state provider * First try for fixing test cases * fixing test cases * PM-24101 fix identified by failing test * Error checking on the orgId * Private method did not need error check * Setting OrganizationId type * Fixing test cases for setting org id * Moving the get of critical apps to the init * The critical apps component was being set again --------- Co-authored-by: voommen-livefront --- .../services/critical-apps.service.spec.ts | 122 +++++++++++------- .../services/critical-apps.service.ts | 43 ++++-- .../all-applications.component.ts | 5 +- .../critical-applications.component.ts | 10 +- .../risk-insights.component.ts | 15 ++- 5 files changed, 123 insertions(+), 72 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts index 96a30d66424..5b40f16ec9e 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts @@ -1,14 +1,13 @@ import { randomUUID } from "crypto"; -import { fakeAsync, flush } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { of, BehaviorSubject } from "rxjs"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; @@ -21,6 +20,17 @@ import { import { CriticalAppsApiService } from "./critical-apps-api.service"; import { CriticalAppsService } from "./critical-apps.service"; +const SomeCsprngArray = new Uint8Array(64) as CsprngArray; +const SomeUser = "some user" as UserId; +const SomeOrganization = "some organization" as OrganizationId; +const AnotherOrganization = "another organization" as OrganizationId; +const SomeOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const AnotherOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const OrgRecords: Record = { + [SomeOrganization]: SomeOrgKey, + [AnotherOrganization]: AnotherOrgKey, +}; + describe("CriticalAppsService", () => { let service: CriticalAppsService; const keyService = mock(); @@ -35,10 +45,6 @@ describe("CriticalAppsService", () => { // reset mocks jest.resetAllMocks(); - - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; - keyService.getOrgKey.mockResolvedValue(mockOrgKey); }); it("should be created", () => { @@ -50,23 +56,27 @@ describe("CriticalAppsService", () => { const criticalApps = ["https://example.com", "https://example.org"]; const request = [ - { organizationId: "org1", url: "encryptedUrlName" }, - { organizationId: "org1", url: "encryptedUrlName" }, + { organizationId: SomeOrganization, url: "encryptedUrlName" }, + { organizationId: SomeOrganization, url: "encryptedUrlName" }, ] as PasswordHealthReportApplicationsRequest[]; const response = [ - { id: "id1", organizationId: "org1", uri: "https://example.com" }, - { id: "id2", organizationId: "org1", uri: "https://example.org" }, + { id: "id1", organizationId: SomeOrganization, uri: "https://example.com" }, + { id: "id2", organizationId: SomeOrganization, uri: "https://example.org" }, ] as PasswordHealthReportApplicationsResponse[]; encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName")); criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response)); + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + + service.setOrganizationId(SomeOrganization, SomeUser); // act - await service.setCriticalApps("org1", criticalApps); + await service.setCriticalApps(SomeOrganization, criticalApps); // expectations - expect(keyService.getOrgKey).toHaveBeenCalledWith("org1"); + expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); expect(encryptService.encryptString).toHaveBeenCalledTimes(2); expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request); }); @@ -77,7 +87,7 @@ describe("CriticalAppsService", () => { service.setAppsInListForOrg([ { id: randomUUID() as PasswordHealthReportApplicationId, - organizationId: "org1" as OrganizationId, + organizationId: SomeOrganization, uri: "https://example.com", }, ]); @@ -87,59 +97,65 @@ describe("CriticalAppsService", () => { // expect only one record to be sent to the server const request = [ - { organizationId: "org1", url: "encryptedUrlName" }, + { organizationId: SomeOrganization, url: "encryptedUrlName" }, ] as PasswordHealthReportApplicationsRequest[]; // mocked response const response = [ - { id: "id1", organizationId: "org1", uri: "test" }, + { id: "id1", organizationId: SomeOrganization, uri: "test" }, ] as PasswordHealthReportApplicationsResponse[]; encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName")); criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response)); + // mock org keys + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + + service.setOrganizationId(SomeOrganization, SomeUser); + // act - await service.setCriticalApps("org1", selectedUrls); + await service.setCriticalApps(SomeOrganization, selectedUrls); // expectations - expect(keyService.getOrgKey).toHaveBeenCalledWith("org1"); + expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); expect(encryptService.encryptString).toHaveBeenCalledTimes(1); expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request); }); - it("should get critical apps", fakeAsync(() => { - const orgId = "org1" as OrganizationId; + it("should get critical apps", () => { const response = [ - { id: "id1", organizationId: "org1", uri: "https://example.com" }, - { id: "id2", organizationId: "org1", uri: "https://example.org" }, + { id: "id1", organizationId: SomeOrganization, uri: "https://example.com" }, + { id: "id2", organizationId: SomeOrganization, uri: "https://example.org" }, ] as PasswordHealthReportApplicationsResponse[]; encryptService.decryptString.mockResolvedValue("https://example.com"); criticalAppsApiService.getCriticalApps.mockReturnValue(of(response)); - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; - keyService.getOrgKey.mockResolvedValue(mockOrgKey); + // mock org keys + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(orgId as OrganizationId); - flush(); + service.setOrganizationId(SomeOrganization, SomeUser); - expect(keyService.getOrgKey).toHaveBeenCalledWith(orgId.toString()); + expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); expect(encryptService.decryptString).toHaveBeenCalledTimes(2); - expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(orgId); - })); + expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(SomeOrganization); + }); it("should get by org id", () => { - const orgId = "org1" as OrganizationId; + const orgId = "some organization" as OrganizationId; const response = [ - { id: "id1", organizationId: "org1", uri: "https://example.com" }, - { id: "id2", organizationId: "org1", uri: "https://example.org" }, - { id: "id3", organizationId: "org2", uri: "https://example.org" }, - { id: "id4", organizationId: "org2", uri: "https://example.org" }, + { id: "id1", organizationId: "some organization", uri: "https://example.com" }, + { id: "id2", organizationId: "some organization", uri: "https://example.org" }, + { id: "id3", organizationId: "another organization", uri: "https://example.org" }, + { id: "id4", organizationId: "another organization", uri: "https://example.org" }, ] as PasswordHealthReportApplicationsResponse[]; + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + service.setOrganizationId(SomeOrganization, SomeUser); service.setAppsInListForOrg(response); - service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { expect(res).toHaveLength(2); }); @@ -147,26 +163,30 @@ describe("CriticalAppsService", () => { it("should drop a critical app", async () => { // arrange - const orgId = "org1" as OrganizationId; const selectedUrl = "https://example.com"; const initialList = [ - { id: "id1", organizationId: "org1", uri: "https://example.com" }, - { id: "id2", organizationId: "org1", uri: "https://example.org" }, + { id: "id1", organizationId: SomeOrganization, uri: "https://example.com" }, + { id: "id2", organizationId: SomeOrganization, uri: "https://example.org" }, ] as PasswordHealthReportApplicationsResponse[]; + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + + service.setOrganizationId(SomeOrganization, SomeUser); + service.setAppsInListForOrg(initialList); // act - await service.dropCriticalApp(orgId, selectedUrl); + await service.dropCriticalApp(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({ - organizationId: orgId, + organizationId: SomeOrganization, passwordHealthReportApplicationIds: ["id1"], }); - expect(service.getAppsListForOrg(orgId)).toBeTruthy(); - service.getAppsListForOrg(orgId).subscribe((res) => { + expect(service.getAppsListForOrg(SomeOrganization)).toBeTruthy(); + service.getAppsListForOrg(SomeOrganization).subscribe((res) => { expect(res).toHaveLength(1); expect(res[0].uri).toBe("https://example.org"); }); @@ -174,23 +194,27 @@ describe("CriticalAppsService", () => { it("should not drop a critical app if it does not exist", async () => { // arrange - const orgId = "org1" as OrganizationId; const selectedUrl = "https://nonexistent.com"; const initialList = [ - { id: "id1", organizationId: "org1", uri: "https://example.com" }, - { id: "id2", organizationId: "org1", uri: "https://example.org" }, + { id: "id1", organizationId: SomeOrganization, uri: "https://example.com" }, + { id: "id2", organizationId: SomeOrganization, uri: "https://example.org" }, ] as PasswordHealthReportApplicationsResponse[]; + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + + service.setOrganizationId(SomeOrganization, SomeUser); + service.setAppsInListForOrg(initialList); // act - await service.dropCriticalApp(orgId, selectedUrl); + await service.dropCriticalApp(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled(); - expect(service.getAppsListForOrg(orgId)).toBeTruthy(); - service.getAppsListForOrg(orgId).subscribe((res) => { + expect(service.getAppsListForOrg(SomeOrganization)).toBeTruthy(); + service.getAppsListForOrg(SomeOrganization).subscribe((res) => { expect(res).toHaveLength(2); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts index f1d4f9d41a3..bdf28624733 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts @@ -1,9 +1,9 @@ import { BehaviorSubject, + filter, first, firstValueFrom, forkJoin, - from, map, Observable, of, @@ -15,7 +15,7 @@ import { import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; @@ -31,6 +31,7 @@ import { CriticalAppsApiService } from "./critical-apps-api.service"; */ export class CriticalAppsService { private orgId = new BehaviorSubject(null); + private orgKey$ = new Observable(); private criticalAppsList = new BehaviorSubject([]); private teardown = new Subject(); @@ -48,7 +49,11 @@ export class CriticalAppsService { ) {} // Get a list of critical apps for a given organization - getAppsListForOrg(orgId: string): Observable { + getAppsListForOrg(orgId: OrganizationId): Observable { + if (orgId != this.orgId.value) { + throw new Error("Organization ID mismatch"); + } + return this.criticalAppsList .asObservable() .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); @@ -60,17 +65,22 @@ export class CriticalAppsService { } // Save the selected critical apps for a given organization - async setCriticalApps(orgId: string, selectedUrls: string[]) { - const key = await this.keyService.getOrgKey(orgId); - if (key == null) { + async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) { + if (orgId != this.orgId.value) { + throw new Error("Organization ID mismatch"); + } + + const orgKey = await firstValueFrom(this.orgKey$); + + if (orgKey == null) { throw new Error("Organization key not found"); } // only save records that are not already in the database const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls); const criticalAppsRequests = await this.encryptNewEntries( - orgId as OrganizationId, - key, + this.orgId.value as OrganizationId, + orgKey, newEntries, ); @@ -83,7 +93,7 @@ export class CriticalAppsService { for (const responseItem of dbResponse) { const decryptedUrl = await this.encryptService.decryptString( new EncString(responseItem.uri), - key, + orgKey, ); if (!updatedList.some((f) => f.uri === decryptedUrl)) { updatedList.push({ @@ -97,13 +107,21 @@ export class CriticalAppsService { } // Get the critical apps for a given organization - setOrganizationId(orgId: OrganizationId) { + setOrganizationId(orgId: OrganizationId, userId: UserId) { + this.orgKey$ = this.keyService.orgKeys$(userId).pipe( + filter((OrgKeys) => !!OrgKeys), + map((organizationKeysById) => organizationKeysById[orgId as OrganizationId]), + ); this.orgId.next(orgId); } // Drop a critical app for a given organization // Only one app may be dropped at a time async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { + if (orgId != this.orgId.value) { + throw new Error("Organization ID mismatch"); + } + const app = this.criticalAppsList.value.find( (f) => f.organizationId === orgId && f.uri === selectedUrl, ); @@ -127,10 +145,7 @@ export class CriticalAppsService { return of([]); } - const result$ = zip( - this.criticalAppsApiService.getCriticalApps(orgId), - from(this.keyService.getOrgKey(orgId)), - ).pipe( + const result$ = zip(this.criticalAppsApiService.getCriticalApps(orgId), this.orgKey$).pipe( switchMap(([response, key]) => { if (key == null) { throw new Error("Organization key not found"); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index 1ce9ab9a2e6..75712c51a3d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -25,6 +25,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { IconButtonModule, @@ -86,7 +87,7 @@ export class AllApplicationsComponent implements OnInit { combineLatest([ this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(organizationId), + this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId), organization$, ]) .pipe( @@ -168,7 +169,7 @@ export class AllApplicationsComponent implements OnInit { try { await this.criticalAppsService.setCriticalApps( - this.organization.id, + this.organization.id as OrganizationId, Array.from(this.selectedUrls), ); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index 58e0f648749..3160107ab92 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -4,7 +4,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, debounceTime, map, switchMap } from "rxjs"; +import { combineLatest, debounceTime, firstValueFrom, map, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { @@ -17,6 +17,8 @@ import { ApplicationHealthReportDetailWithCriticalFlagAndCipher, ApplicationHealthReportSummary, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -61,10 +63,11 @@ export class CriticalApplicationsComponent implements OnInit { async ngOnInit() { this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? ""; - + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); combineLatest([ this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(this.organizationId), + this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), ]) .pipe( takeUntilDestroyed(this.destroyRef), @@ -168,6 +171,7 @@ export class CriticalApplicationsComponent implements OnInit { protected i18nService: I18nService, private configService: ConfigService, private adminTaskService: DefaultAdminTaskService, + private accountService: AccountService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 5f94db0ee3c..e2fb4469ec4 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY, Observable } from "rxjs"; +import { EMPTY, firstValueFrom, Observable } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -15,6 +15,8 @@ import { DrawerType, PasswordHealthReportApplicationsResponse, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { @@ -78,15 +80,16 @@ export class RiskInsightsComponent implements OnInit { private configService: ConfigService, protected dataService: RiskInsightsDataService, private criticalAppsService: CriticalAppsService, + private accountService: AccountService, ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; }); - const orgId = this.route.snapshot.paramMap.get("organizationId") ?? ""; - this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId); } async ngOnInit() { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), @@ -109,7 +112,11 @@ export class RiskInsightsComponent implements OnInit { if (applications) { this.appsCount = applications.length; } - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); + + this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); + this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( + this.organizationId as OrganizationId, + ); }, }); } From 3a62e9c2f188f6e7430a2e5ca5cd5422a9c974e3 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:16:40 +0200 Subject: [PATCH 13/25] [PM-21772] Show key connector domain for new sso users (#15381) * Passed in userId on RemovePasswordComponent. * Added userId on other references to KeyConnectorService methods * remove password component refactor, test coverage, enabled strict * explicit user id provided to key connector service * redirect to / instead when user not logged in or not managing organization * key connector service explicit user id * key connector service no longer requires account service * key connector service missing null type * cli convert to key connector unit tests * remove unnecessary SyncService * error toast not showing on ErrorResponse * bad import due to merge conflict * bad import due to merge conflict * missing loading in remove password component for browser extension * error handling in remove password component * organization observable race condition in key-connector * usesKeyConnector always returns boolean * unit test coverage * key connector reactive * reactive key connector service * introducing convertAccountRequired$ * cli build fix * moving message sending side effect to sync * key connector service unit tests * fix unit tests * move key connector components to KM team ownership * new unit tests in wrong place * key connector domain shown in remove password component * type safety improvements * convert to key connector command localization * key connector domain in convert to key connector command * convert to key connector command unit tests with prompt assert * organization name placement change in the remove password component * unit test update * show key connector domain for new sso users * confirm key connector domain page does not require auth guard * confirm key connector domain page showing correctly * key connector url required to be provided when migrating user * missing locales * desktop styling * have to sync and navigate to vault after key connector keys exchange * logging verbosity * splitting the web client * splitting the browser client * cleanup * splitting the desktop client * cleanup * cleanup * not necessary if condition * key connector domain tests fix for sso componrnt and login strategy * confirm key connector domain base component unit tests coverage * confirm key connector domain command for cli * confirm key connector domain command for cli unit tests * design adjustments removed repeated text, vertical buttons on desktop, wrong paddings on browser extension * key connector service unit test coverage * new linting rules fixes * accept invitation to organization called twice results in error. Web vault remembers it's original route destination, which we do not want in case of accepting invitation and Key Connector, since provisioning new user through SSO and Key Connector, the user is already accepted. * moved required key connector domain confirmation into state * revert redirect from auth guard * cleanup * sso-login.strategy unit test failing * two-factor-auth.component unit test failing * two-factor-auth.component unit test coverage * cli unit test failing * removal of redundant logs * removal of un-necessary new lines * consolidated component * consolidated component css cleanup * use KdfConfig type * consolidate KDF into KdfConfig type in identity token response * moving KC requiresDomainConfirmation lower in order, after successful auth * simplification of trySetUserKeyWithMasterKey * redirect to confirm key connector route when locked but can't unlock yet --------- Co-authored-by: Todd Martin --- apps/browser/src/_locales/en/messages.json | 3 + apps/browser/src/popup/app-routing.module.ts | 20 +- apps/cli/src/auth/commands/login.command.ts | 19 ++ ...nfirm-key-connector-domain.command.spec.ts | 153 ++++++++++++ .../confirm-key-connector-domain.command.ts | 62 +++++ apps/cli/src/locales/en/messages.json | 15 ++ apps/desktop/src/app/app-routing.module.ts | 12 +- apps/desktop/src/locales/en/messages.json | 6 + .../confirm-key-connector-domain.component.ts | 18 ++ apps/web/src/app/oss-routing.module.ts | 12 + apps/web/src/locales/en/messages.json | 3 + .../src/auth/guards/lock.guard.spec.ts | 35 ++- libs/angular/src/auth/guards/lock.guard.ts | 8 + libs/auth/src/angular/sso/sso.component.ts | 11 + .../two-factor-auth.component.spec.ts | 23 ++ .../two-factor-auth.component.ts | 11 + .../common/login-strategies/login.strategy.ts | 19 +- .../sso-login.strategy.spec.ts | 28 ++- .../login-strategies/sso-login.strategy.ts | 18 +- .../webauthn-login.strategy.ts | 4 +- .../response/identity-token.response.ts | 19 +- .../abstractions/key-connector.service.ts | 19 +- .../key-connector-domain-confirmation.ts | 3 + .../new-sso-user-key-connector-conversion.ts | 9 + .../services/key-connector.service.spec.ts | 229 +++++++++++++----- .../services/key-connector.service.ts | 85 ++++--- libs/key-management-ui/src/index.ts | 1 + ...onfirm-key-connector-domain.component.html | 24 ++ ...irm-key-connector-domain.component.spec.ts | 116 +++++++++ .../confirm-key-connector-domain.component.ts | 76 ++++++ 30 files changed, 916 insertions(+), 145 deletions(-) create mode 100644 apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts create mode 100644 apps/cli/src/key-management/confirm-key-connector-domain.command.ts create mode 100644 apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts create mode 100644 libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts create mode 100644 libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts create mode 100644 libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html create mode 100644 libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts create mode 100644 libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3c46085c3d7..ca8e7da5f7b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5594,5 +5594,8 @@ "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." + }, + "confirmKeyConnectorDomain": { + "message": "Confirm Key Connector domain" } } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 34a37da425e..8d190e4555c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -42,7 +42,7 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent } from "@bitwarden/key-management-ui"; +import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; @@ -598,6 +598,24 @@ const routes: Routes = [ }, ], }, + { + path: "confirm-key-connector-domain", + component: ExtensionAnonLayoutWrapperComponent, + canActivate: [], + data: { elevation: 1 } satisfies RouteDataProperties, + children: [ + { + path: "", + component: ConfirmKeyConnectorDomainComponent, + data: { + pageTitle: { + key: "confirmKeyConnectorDomain", + }, + showBackButton: true, + } satisfies ExtensionAnonLayoutWrapperData, + }, + ], + }, { path: "tabs", component: TabsV2Component, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 79414784645..133c9658ae7 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -46,6 +46,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { KdfConfigService, KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; +import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command"; import { Response } from "../../models/response"; import { MessageResponse } from "../../models/response/message.response"; @@ -332,6 +333,24 @@ export class LoginCommand { ); } + // Check if Key Connector domain confirmation is required + const domainConfirmation = await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(response.userId), + ); + if (domainConfirmation != null) { + const command = new ConfirmKeyConnectorDomainCommand( + response.userId, + domainConfirmation.keyConnectorUrl, + this.keyConnectorService, + this.logoutCallback, + this.i18nService, + ); + const confirmResponse = await command.run(); + if (!confirmResponse.success) { + return confirmResponse; + } + } + // Run full sync before handling success response or password reset flows (to get Master Password Policies) await this.syncService.fullSync(true, { skipTokenRefresh: true }); diff --git a/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts b/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts new file mode 100644 index 00000000000..7da0fbb35de --- /dev/null +++ b/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts @@ -0,0 +1,153 @@ +import { createPromptModule } from "inquirer"; +import { mock } from "jest-mock-extended"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Response } from "../models/response"; +import { MessageResponse } from "../models/response/message.response"; +import { I18nService } from "../platform/services/i18n.service"; + +import { ConfirmKeyConnectorDomainCommand } from "./confirm-key-connector-domain.command"; + +jest.mock("inquirer", () => { + return { + createPromptModule: jest.fn(() => jest.fn(() => Promise.resolve({ confirm: "" }))), + }; +}); + +describe("ConfirmKeyConnectorDomainCommand", () => { + let command: ConfirmKeyConnectorDomainCommand; + + const userId = "test-user-id" as UserId; + const keyConnectorUrl = "https://keyconnector.example.com"; + + const keyConnectorService = mock(); + const logout = jest.fn(); + const i18nService = mock(); + + beforeEach(async () => { + command = new ConfirmKeyConnectorDomainCommand( + userId, + keyConnectorUrl, + keyConnectorService, + logout, + i18nService, + ); + + i18nService.t.mockImplementation((key: string) => { + switch (key) { + case "confirmKeyConnectorDomain": + return "Please confirm the domain below with your organization administrator. Key Connector domain: https://keyconnector.example.com"; + case "confirm": + return "Confirm"; + case "logOut": + return "Log out"; + case "youHaveBeenLoggedOut": + return "You have been logged out."; + case "organizationUsingKeyConnectorConfirmLoggedOut": + return "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out."; + default: + return ""; + } + }); + }); + + describe("run", () => { + it("should logout and return error response if no interaction available", async () => { + process.env.BW_NOINTERACTION = "true"; + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response).toEqual( + Response.error( + new MessageResponse( + "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.", + null, + ), + ), + ); + expect(logout).toHaveBeenCalled(); + }); + + it("should logout and return error response if interaction answer is cancel", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "cancel" }); + }), + ); + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response).toEqual(Response.error("You have been logged out.")); + expect(logout).toHaveBeenCalled(); + }); + + it("should convert new sso user to key connector and return success response if answer is confirmed", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "confirmed" }); + }), + ); + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId); + }); + + it("should logout and throw error if convert new sso user to key connector failed", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "confirmed" }); + }), + ); + + keyConnectorService.convertNewSsoUserToKeyConnector.mockRejectedValue( + new Error("Migration failed"), + ); + + await expect(command.run()).rejects.toThrow("Migration failed"); + expect(logout).toHaveBeenCalled(); + }); + + function assertPrompt(prompt: unknown) { + expect(typeof prompt).toEqual("object"); + expect(prompt).toHaveProperty("type"); + expect(prompt).toHaveProperty("name"); + expect(prompt).toHaveProperty("message"); + expect(prompt).toHaveProperty("choices"); + const promptObj = prompt as Record; + expect(promptObj["type"]).toEqual("list"); + expect(promptObj["name"]).toEqual("confirm"); + expect(promptObj["message"]).toEqual( + `Please confirm the domain below with your organization administrator. Key Connector domain: ${keyConnectorUrl}`, + ); + expect(promptObj["choices"]).toBeInstanceOf(Array); + const choices = promptObj["choices"] as Array>; + expect(choices).toHaveLength(2); + expect(choices[0]).toEqual({ + name: "Confirm", + value: "confirmed", + }); + expect(choices[1]).toEqual({ + name: "Log out", + value: "cancel", + }); + } + }); +}); diff --git a/apps/cli/src/key-management/confirm-key-connector-domain.command.ts b/apps/cli/src/key-management/confirm-key-connector-domain.command.ts new file mode 100644 index 00000000000..da53f706d0d --- /dev/null +++ b/apps/cli/src/key-management/confirm-key-connector-domain.command.ts @@ -0,0 +1,62 @@ +import * as inquirer from "inquirer"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Response } from "../models/response"; +import { MessageResponse } from "../models/response/message.response"; + +export class ConfirmKeyConnectorDomainCommand { + constructor( + private readonly userId: UserId, + private readonly keyConnectorUrl: string, + private keyConnectorService: KeyConnectorService, + private logout: () => Promise, + private i18nService: I18nService, + ) {} + + async run(): Promise { + // If no interaction available, alert user to use web vault + const canInteract = process.env.BW_NOINTERACTION !== "true"; + if (!canInteract) { + await this.logout(); + return Response.error( + new MessageResponse( + this.i18nService.t("organizationUsingKeyConnectorConfirmLoggedOut"), + null, + ), + ); + } + + const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ + type: "list", + name: "confirm", + message: this.i18nService.t("confirmKeyConnectorDomain", this.keyConnectorUrl), + choices: [ + { + name: this.i18nService.t("confirm"), + value: "confirmed", + }, + { + name: this.i18nService.t("logOut"), + value: "cancel", + }, + ], + }); + + if (answer.confirm === "confirmed") { + try { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId); + } catch (e) { + await this.logout(); + throw e; + } + + return Response.success(); + } else { + await this.logout(); + return Response.error(this.i18nService.t("youHaveBeenLoggedOut")); + } + } +} diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index cb7f89781dd..4a8c774ea42 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -218,5 +218,20 @@ }, "myItems": { "message": "My Items" + }, + "organizationUsingKeyConnectorConfirmLoggedOut": { + "message": "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out." + }, + "confirmKeyConnectorDomain": { + "message": "Please confirm the domain below with your organization administrator. Key Connector domain: $KEYCONNECTORDOMAIN$", + "placeholders": { + "keyConnectorDomain": { + "content": "$1", + "example": "Key Connector domain" + } + } + }, + "confirm": { + "message": "Confirm" } } diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 1cfe8a85386..2a65dff33bd 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -38,7 +38,7 @@ import { NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent } from "@bitwarden/key-management-ui"; +import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; @@ -296,6 +296,16 @@ const routes: Routes = [ component: ChangePasswordComponent, canActivate: [authGuard], }, + { + path: "confirm-key-connector-domain", + component: ConfirmKeyConnectorDomainComponent, + canActivate: [], + data: { + pageTitle: { + key: "confirmKeyConnectorDomain", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, ], }, ]; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 1094a8be26f..b2005a37472 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4083,5 +4083,11 @@ }, "next": { "message": "Next" + }, + "confirmKeyConnectorDomain": { + "message": "Confirm Key Connector domain" + }, + "confirm": { + "message": "Confirm" } } diff --git a/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts b/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts new file mode 100644 index 00000000000..6127bd25a6f --- /dev/null +++ b/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; + +import { ConfirmKeyConnectorDomainComponent as BaseConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { RouterService } from "@bitwarden/web-vault/app/core"; + +@Component({ + selector: "app-confirm-key-connector-domain", + template: ` `, + standalone: true, + imports: [BaseConfirmKeyConnectorDomainComponent], +}) +export class ConfirmKeyConnectorDomainComponent { + constructor(private routerService: RouterService) {} + + onBeforeNavigation = async () => { + await this.routerService.getAndClearLoginRedirectUrl(); + }; +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 9536ba19cc8..c0b1f9d1622 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -67,6 +67,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; import { RouteDataProperties } from "./core"; import { ReportsModule } from "./dirt/reports"; +import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; @@ -511,6 +512,17 @@ const routes: Routes = [ titleId: "removeMasterPassword", } satisfies RouteDataProperties & AnonLayoutWrapperData, }, + { + path: "confirm-key-connector-domain", + component: ConfirmKeyConnectorDomainComponent, + canActivate: [], + data: { + pageTitle: { + key: "confirmKeyConnectorDomain", + }, + titleId: "confirmKeyConnectorDomain", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "trial-initiation", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index e70c35c773c..83c266cfbfc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11166,6 +11166,9 @@ "example": "92873837267" } } + }, + "confirmKeyConnectorDomain": { + "message": "Confirm Key Connector domain" } } diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index 53491bace00..da89ee786b7 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -15,6 +15,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ClientType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -33,29 +35,35 @@ interface SetupParams { } describe("lockGuard", () => { + const keyConnectorService = mock(); + const setup = (setupParams: SetupParams) => { const authService: MockProxy = mock(); authService.authStatusFor$.mockReturnValue(of(setupParams.authStatus)); const vaultTimeoutSettingsService: MockProxy = mock(); - vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock); + vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock ?? true); const keyService: MockProxy = mock(); - keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey)); + keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey ?? true)); const platformUtilService: MockProxy = mock(); - platformUtilService.getClientType.mockReturnValue(setupParams.clientType); + platformUtilService.getClientType.mockReturnValue(setupParams.clientType ?? ClientType.Web); const messagingService: MockProxy = mock(); const deviceTrustService: MockProxy = mock(); - deviceTrustService.supportsDeviceTrust$ = of(setupParams.supportsDeviceTrust); + deviceTrustService.supportsDeviceTrust$ = of(setupParams.supportsDeviceTrust ?? false); const userVerificationService: MockProxy = mock(); - userVerificationService.hasMasterPassword.mockResolvedValue(setupParams.hasMasterPassword); + userVerificationService.hasMasterPassword.mockResolvedValue( + setupParams.hasMasterPassword ?? true, + ); + + keyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null)); const accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject(null); @@ -77,6 +85,7 @@ describe("lockGuard", () => { { path: "", component: EmptyComponent }, { path: "lock", component: EmptyComponent, canActivate: [lockGuard()] }, { path: "non-lock-route", component: EmptyComponent }, + { path: "confirm-key-connector-domain", component: EmptyComponent }, ]), ], providers: [ @@ -88,6 +97,7 @@ describe("lockGuard", () => { { provide: PlatformUtilsService, useValue: platformUtilService }, { provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService }, { provide: UserVerificationService, useValue: userVerificationService }, + { provide: KeyConnectorService, useValue: keyConnectorService }, ], }); @@ -193,4 +203,19 @@ describe("lockGuard", () => { await router.navigate(["lock"]); expect(router.url).toBe("/"); }); + + it("should redirect to the confirm-key-connector-domain route when the auth status is locked, can't lock and requires key connector domain confirmation", async () => { + const { router } = setup({ + authStatus: AuthenticationStatus.Locked, + canLock: false, + }); + keyConnectorService.requiresDomainConfirmation$.mockReturnValue( + of({ + keyConnectorUrl: "https://example.com", + } as KeyConnectorDomainConfirmation), + ); + + await router.navigate(["lock"]); + expect(router.url).toBe("/confirm-key-connector-domain"); + }); }); diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index 8acdadeb87c..230bb00dc1f 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -12,6 +12,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { KeyService } from "@bitwarden/key-management"; @@ -34,6 +35,7 @@ export function lockGuard(): CanActivateFn { const userVerificationService = inject(UserVerificationService); const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); const accountService = inject(AccountService); + const keyConnectorService = inject(KeyConnectorService); const activeUser = await firstValueFrom(accountService.activeAccount$); @@ -48,6 +50,12 @@ export function lockGuard(): CanActivateFn { return router.createUrlTree(["/"]); } + if ( + (await firstValueFrom(keyConnectorService.requiresDomainConfirmation$(activeUser.id))) != null + ) { + return router.createUrlTree(["confirm-key-connector-domain"]); + } + // if user can't lock, they can't access the lock screen const canLock = await vaultTimeoutSettingsService.canLock(activeUser.id); if (!canLock) { diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index 8acd6865b70..8cc30bd62a6 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -24,6 +24,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; @@ -116,6 +117,7 @@ export class SsoComponent implements OnInit { private toastService: ToastService, private ssoComponentService: SsoComponentService, private loginSuccessHandlerService: LoginSuccessHandlerService, + private keyConnectorService: KeyConnectorService, ) { environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => { this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html"; @@ -444,6 +446,15 @@ export class SsoComponent implements OnInit { authResult.userId, ); + if ( + (await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(authResult.userId), + )) != null + ) { + await this.router.navigate(["confirm-key-connector-domain"]); + return; + } + // must come after 2fa check since user decryption options aren't available if 2fa is required const userDecryptionOpts = await firstValueFrom( this.userDecryptionOptionsService.userDecryptionOptions$, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 62271feee59..9418030d7a1 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -24,6 +24,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication- import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, @@ -79,6 +80,7 @@ describe("TwoFactorAuthComponent", () => { let mockTwoFactorAuthCompCacheService: MockProxy; let mockAuthService: MockProxy; let mockConfigService: MockProxy; + let mockKeyConnnectorService: MockProxy; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -115,6 +117,8 @@ describe("TwoFactorAuthComponent", () => { mockTwoFactorAuthCompService = mock(); mockAuthService = mock(); mockConfigService = mock(); + mockKeyConnnectorService = mock(); + mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue(of(null)); mockEnvService = mock(); mockLoginSuccessHandlerService = mock(); @@ -215,6 +219,7 @@ describe("TwoFactorAuthComponent", () => { { provide: AuthService, useValue: mockAuthService }, { provide: ConfigService, useValue: mockConfigService }, { provide: MasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: KeyConnectorService, useValue: mockKeyConnnectorService }, ], }); @@ -404,6 +409,24 @@ describe("TwoFactorAuthComponent", () => { }); }); }); + + it("navigates to /confirm-key-connector-domain when Key Connector is enabled and user has no master password", async () => { + selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector); + mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue( + of({ + keyConnectorUrl: + mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption! + .keyConnectorUrl, + }), + ); + const authResult = new AuthResult(); + authResult.userId = userId; + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + + await component.submit(token, remember); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["confirm-key-connector-domain"]); + }); }); }); }); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 035598b873b..4c0784928d4 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -38,6 +38,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -166,6 +167,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { private loginSuccessHandlerService: LoginSuccessHandlerService, private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService, private authService: AuthService, + private keyConnectorService: KeyConnectorService, ) {} async ngOnInit() { @@ -455,6 +457,15 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { ); } + if ( + (await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(authResult.userId), + )) != null + ) { + await this.router.navigate(["confirm-key-connector-domain"]); + return; + } + const userDecryptionOpts = await firstValueFrom( this.userDecryptionOptionsService.userDecryptionOptions$, ); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 4c7a38254d7..88c247ba711 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -33,13 +33,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { UserId } from "@bitwarden/common/types/guid"; -import { - KeyService, - Argon2KdfConfig, - PBKDF2KdfConfig, - KdfConfigService, - KdfType, -} from "@bitwarden/key-management"; +import { KeyService, KdfConfigService } from "@bitwarden/key-management"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { @@ -220,16 +214,7 @@ export abstract class LoginStrategy { tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token. ); - await this.KdfConfigService.setKdfConfig( - userId as UserId, - tokenResponse.kdf === KdfType.PBKDF2_SHA256 - ? new PBKDF2KdfConfig(tokenResponse.kdfIterations) - : new Argon2KdfConfig( - tokenResponse.kdfIterations, - tokenResponse.kdfMemory, - tokenResponse.kdfParallelism, - ), - ); + await this.KdfConfigService.setKdfConfig(userId as UserId, tokenResponse.kdfConfig); await this.billingAccountProfileStateService.setHasPremium( accountInformation.premium ?? false, diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index f057dc47c63..bce05b35e62 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -33,8 +33,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key"; -import { KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { DeviceKey, MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { Argon2KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management"; import { AuthRequestServiceAbstraction, @@ -518,15 +518,19 @@ describe("SsoLoginStrategy", () => { }); it("converts new SSO user with no master password to Key Connector on first login", async () => { - tokenResponse.key = null; + tokenResponse.key = undefined; + tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4); apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); - expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( - tokenResponse, - ssoOrgId, + expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith( + { + kdfConfig: new Argon2KdfConfig(10, 64, 4), + keyConnectorUrl: keyConnectorUrl, + organizationId: ssoOrgId, + }, userId, ); }); @@ -574,15 +578,19 @@ describe("SsoLoginStrategy", () => { }); it("converts new SSO user with no master password to Key Connector on first login", async () => { - tokenResponse.key = null; + tokenResponse.key = undefined; + tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4); apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); - expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( - tokenResponse, - ssoOrgId, + expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith( + { + kdfConfig: new Argon2KdfConfig(10, 64, 4), + keyConnectorUrl: keyConnectorUrl, + organizationId: ssoOrgId, + }, userId, ); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 6f1231b3559..ec7914b087e 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -125,9 +125,13 @@ export class SsoLoginStrategy extends LoginStrategy { // The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector. const newSsoUser = tokenResponse.key == null; if (newSsoUser) { - await this.keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - this.cache.value.orgId, + // Store Key Connector domain confirmation data in state instead of AuthResult + await this.keyConnectorService.setNewSsoUserKeyConnectorConversionData( + { + kdfConfig: tokenResponse.kdfConfig, + keyConnectorUrl: this.getKeyConnectorUrl(tokenResponse), + organizationId: this.cache.value.orgId, + }, userId, ); } else { @@ -327,10 +331,12 @@ export class SsoLoginStrategy extends LoginStrategy { private async trySetUserKeyWithMasterKey(userId: UserId): Promise { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - // There is a scenario in which the master key is not set here. That will occur if the user - // has a master password and is using Key Connector. In that case, we cannot set the master key + // There are two scenarios in which the master key is not set here: + // 1. If the user has a master password and is using Key Connector. In that case, we cannot set the master key // because the user hasn't entered their master password yet. - // Instead, we'll return here and let the migration to Key Connector handle setting the master key. + // 2. For new users with Key Connector, we will not have a master key yet, since Key Connector domain + // has to be confirmed first. + // In both cases, we'll return here and let the migration to Key Connector handle setting the master key. if (!masterKey) { return; } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index a6677350ee9..dbce7628335 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -57,9 +57,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { throw new Error("2FA not supported yet for WebAuthn Login."); } - protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) { - return Promise.resolve(); - } + protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) {} protected override async setUserKey(idTokenResponse: IdentityTokenResponse, userId: UserId) { const masterKeyEncryptedUserKey = idTokenResponse.key; diff --git a/libs/common/src/auth/models/response/identity-token.response.ts b/libs/common/src/auth/models/response/identity-token.response.ts index 53242a25b21..2a63986a8ab 100644 --- a/libs/common/src/auth/models/response/identity-token.response.ts +++ b/libs/common/src/auth/models/response/identity-token.response.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { KdfType } from "@bitwarden/key-management"; +import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { BaseResponse } from "../../../models/response/base.response"; @@ -20,10 +20,7 @@ export class IdentityTokenResponse extends BaseResponse { privateKey: string; // userKeyEncryptedPrivateKey key?: EncString; // masterKeyEncryptedUserKey twoFactorToken: string; - kdf: KdfType; - kdfIterations: number; - kdfMemory?: number; - kdfParallelism?: number; + kdfConfig: KdfConfig; forcePasswordReset: boolean; masterPasswordPolicy: MasterPasswordPolicyResponse; apiUseKeyConnector: boolean; @@ -45,10 +42,14 @@ export class IdentityTokenResponse extends BaseResponse { this.key = new EncString(key); } this.twoFactorToken = this.getResponseProperty("TwoFactorToken"); - this.kdf = this.getResponseProperty("Kdf"); - this.kdfIterations = this.getResponseProperty("KdfIterations"); - this.kdfMemory = this.getResponseProperty("KdfMemory"); - this.kdfParallelism = this.getResponseProperty("KdfParallelism"); + const kdf = this.getResponseProperty("Kdf"); + const kdfIterations = this.getResponseProperty("KdfIterations"); + const kdfMemory = this.getResponseProperty("KdfMemory"); + const kdfParallelism = this.getResponseProperty("KdfParallelism"); + this.kdfConfig = + kdf == KdfType.PBKDF2_SHA256 + ? new PBKDF2KdfConfig(kdfIterations) + : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset"); this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector"); this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl"); diff --git a/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts b/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts index ed87160832d..c3fedc3333a 100644 --- a/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts @@ -1,8 +1,10 @@ import { Observable } from "rxjs"; +import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion"; + import { Organization } from "../../../admin-console/models/domain/organization"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { UserId } from "../../../types/guid"; +import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation"; export abstract class KeyConnectorService { abstract setMasterKeyFromUrl(keyConnectorUrl: string, userId: UserId): Promise; @@ -13,13 +15,18 @@ export abstract class KeyConnectorService { abstract migrateUser(keyConnectorUrl: string, userId: UserId): Promise; - abstract convertNewSsoUserToKeyConnector( - tokenResponse: IdentityTokenResponse, - orgId: string, - userId: UserId, - ): Promise; + abstract convertNewSsoUserToKeyConnector(userId: UserId): Promise; abstract setUsesKeyConnector(enabled: boolean, userId: UserId): Promise; + abstract setNewSsoUserKeyConnectorConversionData( + conversion: NewSsoUserKeyConnectorConversion, + userId: UserId, + ): Promise; + + abstract requiresDomainConfirmation$( + userId: UserId, + ): Observable; + abstract convertAccountRequired$: Observable; } diff --git a/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts new file mode 100644 index 00000000000..277057485c1 --- /dev/null +++ b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts @@ -0,0 +1,3 @@ +export interface KeyConnectorDomainConfirmation { + keyConnectorUrl: string; +} diff --git a/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts b/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts new file mode 100644 index 00000000000..12996747c96 --- /dev/null +++ b/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts @@ -0,0 +1,9 @@ +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + +export interface NewSsoUserKeyConnectorConversion { + kdfConfig: KdfConfig; + keyConnectorUrl: string; + organizationId: string; +} diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index 67961616034..bb458ff49f4 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -3,16 +3,17 @@ import { firstValueFrom, of, timeout, TimeoutError } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-connector/models/set-key-connector-key.request"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { KdfType, KeyService } from "@bitwarden/key-management"; +import { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationData } from "../../../admin-console/models/data/organization.data"; import { Organization } from "../../../admin-console/models/domain/organization"; import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response"; import { TokenService } from "../../../auth/services/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; @@ -24,8 +25,13 @@ import { KeyGenerationService } from "../../crypto"; import { EncString } from "../../crypto/models/enc-string"; import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; +import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion"; -import { USES_KEY_CONNECTOR, KeyConnectorService } from "./key-connector.service"; +import { + USES_KEY_CONNECTOR, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + KeyConnectorService, +} from "./key-connector.service"; describe("KeyConnectorService", () => { let keyConnectorService: KeyConnectorService; @@ -36,6 +42,7 @@ describe("KeyConnectorService", () => { const logService = mock(); const organizationService = mock(); const keyGenerationService = mock(); + const logoutCallback = jest.fn(); let stateProvider: FakeStateProvider; @@ -51,6 +58,12 @@ describe("KeyConnectorService", () => { const keyConnectorUrl = "https://key-connector-url.com"; + const conversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: new PBKDF2KdfConfig(600_000), + keyConnectorUrl, + organizationId: mockOrgId, + }; + beforeEach(() => { jest.resetAllMocks(); @@ -67,7 +80,7 @@ describe("KeyConnectorService", () => { logService, organizationService, keyGenerationService, - async () => {}, + logoutCallback, stateProvider, ); }); @@ -406,28 +419,21 @@ describe("KeyConnectorService", () => { }); describe("convertNewSsoUserToKeyConnector", () => { - const tokenResponse = mock(); const passwordKey = new SymmetricCryptoKey(new Uint8Array(64)); const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockEmail = "test@example.com"; const mockMasterKey = getMockMasterKey(); + const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [ + string, + EncString, + ]; let mockMakeUserKeyResult: [UserKey, EncString]; beforeEach(() => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [ - string, - EncString, - ]; const encString = new EncString("mockEncryptedString"); mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString]; - tokenResponse.kdf = KdfType.PBKDF2_SHA256; - tokenResponse.kdfIterations = 100000; - tokenResponse.kdfMemory = 16; - tokenResponse.kdfParallelism = 4; - tokenResponse.keyConnectorUrl = keyConnectorUrl; - keyGenerationService.createKey.mockResolvedValue(passwordKey); keyService.makeMasterKey.mockResolvedValue(mockMasterKey); keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult); @@ -435,56 +441,85 @@ describe("KeyConnectorService", () => { tokenService.getEmail.mockResolvedValue(mockEmail); }); - it("sets up a new SSO user with key connector", async () => { - await keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - mockOrgId, - mockUserId, - ); + it.each([ + [KdfType.PBKDF2_SHA256, 700_000, undefined, undefined], + [KdfType.Argon2id, 11, 65, 5], + ])( + "sets up a new SSO user with key connector", + async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => { + const expectedKdfConfig = + kdfType == KdfType.PBKDF2_SHA256 + ? new PBKDF2KdfConfig(kdfIterations) + : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); - expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); - expect(keyService.makeMasterKey).toHaveBeenCalledWith( - passwordKey.keyB64, - mockEmail, - expect.any(Object), - ); - expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( - mockMasterKey, - mockUserId, - ); - expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId); - expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( - mockMakeUserKeyResult[1], - mockUserId, - ); - expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); - expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( - tokenResponse.keyConnectorUrl, - expect.any(KeyConnectorUserKeyRequest), - ); - expect(apiService.postSetKeyConnectorKey).toHaveBeenCalled(); - }); + const conversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: expectedKdfConfig, + keyConnectorUrl: keyConnectorUrl, + organizationId: mockOrgId, + }; + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(conversion); + + await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId); + + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + passwordKey.keyB64, + mockEmail, + expectedKdfConfig, + ); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockMasterKey, + mockUserId, + ); + expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId); + expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + mockMakeUserKeyResult[1], + mockUserId, + ); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + keyConnectorUrl, + new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey), + ), + ); + expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith( + new SetKeyConnectorKeyRequest( + mockMakeUserKeyResult[1].encryptedString!, + expectedKdfConfig, + mockOrgId, + new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!), + ), + ); + + // Verify that conversion data is cleared from conversionState + expect(await firstValueFrom(conversionState.state$)).toBeNull(); + }, + ); it("handles api error", async () => { apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error")); - try { - await keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - mockOrgId, - mockUserId, - ); - } catch (error: any) { - expect(error).toBeInstanceOf(Error); - expect(error?.message).toBe("Key Connector error"); - } + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(conversion); + + await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow( + new Error("Key Connector error"), + ); expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); expect(keyService.makeMasterKey).toHaveBeenCalledWith( passwordKey.keyB64, mockEmail, - expect.any(Object), + new PBKDF2KdfConfig(600_000), ); expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( mockMasterKey, @@ -498,10 +533,90 @@ describe("KeyConnectorService", () => { ); expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( - tokenResponse.keyConnectorUrl, - expect.any(KeyConnectorUserKeyRequest), + keyConnectorUrl, + new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)), ); expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled(); + expect(await firstValueFrom(conversionState.state$)).toEqual(conversion); + + expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError"); + }); + + it("should throw error when conversion data is null", async () => { + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(null); + + await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow( + new Error("Key Connector conversion not found"), + ); + + // Verify that no key generation or API calls were made + expect(keyGenerationService.createKey).not.toHaveBeenCalled(); + expect(keyService.makeMasterKey).not.toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled(); + expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled(); + }); + }); + + describe("setNewSsoUserKeyConnectorConversionData", () => { + it("should store Key Connector domain confirmation data in state", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(null); + + await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId); + + expect(await firstValueFrom(state.state$)).toEqual(conversion); + }); + + it("should overwrite existing Key Connector domain confirmation data", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + const existingConversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: new Argon2KdfConfig(3, 64, 4), + keyConnectorUrl: "https://old.example.com", + organizationId: "old-org-id" as OrganizationId, + }; + state.nextState(existingConversion); + + await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId); + + expect(await firstValueFrom(state.state$)).toEqual(conversion); + }); + }); + + describe("requiresDomainConfirmation$", () => { + it("should return observable of key connector domain confirmation value when set", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(conversion); + + const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId); + const data = await firstValueFrom(data$); + + expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl }); + }); + + it("should return observable of null value when no data is set", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(null); + + const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId); + const data = await firstValueFrom(data$); + + expect(data).toBeNull(); }); }); diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index a6207ab92e2..f6730cf8870 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -1,27 +1,21 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { combineLatest, filter, firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { - Argon2KdfConfig, - KdfConfig, - PBKDF2KdfConfig, - KeyService, - KdfType, -} from "@bitwarden/key-management"; +import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../../admin-console/enums"; import { Organization } from "../../../admin-console/models/domain/organization"; import { TokenService } from "../../../auth/abstractions/token.service"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeysRequest } from "../../../models/request/keys.request"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; @@ -32,6 +26,7 @@ import { MasterKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; +import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../models/set-key-connector-key.request"; @@ -45,6 +40,27 @@ export const USES_KEY_CONNECTOR = new UserKeyDefinition( }, ); +export const NEW_SSO_USER_KEY_CONNECTOR_CONVERSION = + new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "newSsoUserKeyConnectorConversion", + { + deserializer: (conversion) => + conversion == null + ? null + : { + kdfConfig: + conversion.kdfConfig.kdfType === KdfType.PBKDF2_SHA256 + ? PBKDF2KdfConfig.fromJSON(conversion.kdfConfig) + : Argon2KdfConfig.fromJSON(conversion.kdfConfig), + keyConnectorUrl: conversion.keyConnectorUrl, + organizationId: conversion.organizationId, + }, + clearOn: ["logout"], + cleanupDelayMs: 0, + }, + ); + export class KeyConnectorService implements KeyConnectorServiceAbstraction { readonly convertAccountRequired$: Observable; @@ -128,25 +144,17 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { return this.findManagingOrganization(organizations); } - async convertNewSsoUserToKeyConnector( - tokenResponse: IdentityTokenResponse, - orgId: string, - userId: UserId, - ) { - // TODO: Remove after tokenResponse.keyConnectorUrl is deprecated in 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) - const { - kdf, - kdfIterations, - kdfMemory, - kdfParallelism, - keyConnectorUrl: legacyKeyConnectorUrl, - userDecryptionOptions, - } = tokenResponse; + async convertNewSsoUserToKeyConnector(userId: UserId) { + const conversion = await firstValueFrom( + this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId), + ); + if (conversion == null) { + throw new Error("Key Connector conversion not found"); + } + + const { kdfConfig, keyConnectorUrl, organizationId } = conversion; + const password = await this.keyGenerationService.createKey(512); - const kdfConfig: KdfConfig = - kdf === KdfType.PBKDF2_SHA256 - ? new PBKDF2KdfConfig(kdfIterations) - : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); const masterKey = await this.keyService.makeMasterKey( password.keyB64, @@ -165,8 +173,6 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]); try { - const keyConnectorUrl = - legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl; await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest); } catch (e) { this.handleKeyConnectorError(e); @@ -176,10 +182,29 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const setPasswordRequest = new SetKeyConnectorKeyRequest( userKey[1].encryptedString, kdfConfig, - orgId, + organizationId, keys, ); await this.apiService.postSetKeyConnectorKey(setPasswordRequest); + + await this.stateProvider + .getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION) + .update(() => null); + } + + async setNewSsoUserKeyConnectorConversionData( + conversion: NewSsoUserKeyConnectorConversion, + userId: UserId, + ): Promise { + await this.stateProvider + .getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION) + .update(() => conversion); + } + + requiresDomainConfirmation$(userId: UserId): Observable { + return this.stateProvider + .getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId) + .pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null))); } private handleKeyConnectorError(e: any) { diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b330e390d36..6754722440a 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -8,3 +8,4 @@ export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component"; export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; export { RemovePasswordComponent } from "./key-connector/remove-password.component"; +export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component"; diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html new file mode 100644 index 00000000000..6cf151d4604 --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html @@ -0,0 +1,24 @@ +@if (loading) { +
+ + {{ "loading" | i18n }} +
+} @else { +
+

{{ "keyConnectorDomain" | i18n }}:

+

{{ keyConnectorUrl }}

+
+ +
+ + +
+} diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts new file mode 100644 index 00000000000..b53b0a196f5 --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts @@ -0,0 +1,116 @@ +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { ConfirmKeyConnectorDomainComponent } from "./confirm-key-connector-domain.component"; + +describe("ConfirmKeyConnectorDomainComponent", () => { + let component: ConfirmKeyConnectorDomainComponent; + + const userId = "test-user-id" as UserId; + const confirmation: KeyConnectorDomainConfirmation = { + keyConnectorUrl: "https://key-connector-url.com", + }; + + const mockRouter = mock(); + const mockSyncService = mock(); + const mockKeyConnectorService = mock(); + const mockLogService = mock(); + const mockMessagingService = mock(); + let mockAccountService = mockAccountServiceWith(userId); + const onBeforeNavigation = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + mockAccountService = mockAccountServiceWith(userId); + + component = new ConfirmKeyConnectorDomainComponent( + mockRouter, + mockLogService, + mockKeyConnectorService, + mockMessagingService, + mockSyncService, + mockAccountService, + ); + + jest.spyOn(component, "onBeforeNavigation").mockImplementation(onBeforeNavigation); + + // Mock key connector service to return data from state + mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(confirmation)); + }); + + describe("ngOnInit", () => { + it("should logout when no active account", async () => { + mockAccountService.activeAccount$ = of(null); + + await component.ngOnInit(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(component.loading).toEqual(true); + }); + + it("should logout when confirmation is null", async () => { + mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null)); + + await component.ngOnInit(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(component.loading).toEqual(true); + }); + + it("should set component properties correctly", async () => { + await component.ngOnInit(); + + expect(component.userId).toEqual(userId); + expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl); + expect(component.loading).toEqual(false); + }); + }); + + describe("confirm", () => { + it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => { + await component.ngOnInit(); + + await component.confirm(); + + expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + expect(mockMessagingService.send).toHaveBeenCalledWith("loggedIn"); + expect(onBeforeNavigation).toHaveBeenCalled(); + + expect( + mockKeyConnectorService.convertNewSsoUserToKeyConnector.mock.invocationCallOrder[0], + ).toBeLessThan(mockSyncService.fullSync.mock.invocationCallOrder[0]); + expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan( + mockMessagingService.send.mock.invocationCallOrder[0], + ); + expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan( + onBeforeNavigation.mock.invocationCallOrder[0], + ); + expect(onBeforeNavigation.mock.invocationCallOrder[0]).toBeLessThan( + mockRouter.navigate.mock.invocationCallOrder[0], + ); + }); + }); + + describe("cancel", () => { + it("should logout", async () => { + await component.ngOnInit(); + + await component.cancel(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts new file mode 100644 index 00000000000..586c1cc113a --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BitActionDirective, ButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + selector: "confirm-key-connector-domain", + templateUrl: "confirm-key-connector-domain.component.html", + standalone: true, + imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective], +}) +export class ConfirmKeyConnectorDomainComponent implements OnInit { + loading = true; + keyConnectorUrl!: string; + userId!: UserId; + + @Input() onBeforeNavigation: () => Promise = async () => {}; + + constructor( + private router: Router, + private logService: LogService, + private keyConnectorService: KeyConnectorService, + private messagingService: MessagingService, + private syncService: SyncService, + private accountService: AccountService, + ) {} + + async ngOnInit() { + try { + this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + } catch { + this.logService.info("[confirm-key-connector-domain] no active account"); + this.messagingService.send("logout"); + return; + } + + const confirmation = await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(this.userId), + ); + if (confirmation == null) { + this.logService.info("[confirm-key-connector-domain] missing required parameters"); + this.messagingService.send("logout"); + return; + } + + this.keyConnectorUrl = confirmation.keyConnectorUrl; + + this.loading = false; + } + + confirm = async () => { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId); + + await this.syncService.fullSync(true); + + this.messagingService.send("loggedIn"); + + await this.onBeforeNavigation(); + + await this.router.navigate(["/"]); + }; + + cancel = async () => { + this.messagingService.send("logout"); + }; +} From 363d6bea44aa510053460c3648cf727d2010487d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 3 Sep 2025 15:28:15 -0400 Subject: [PATCH 14/25] [PM-24550] Remove Feature Flag Code for PM-12276 (#16173) * tests: remove feature flag use in tests * tests: remove breadcrumbingPolicyTests and add service tests * refactor: remove event log use of flag from org-layout component * refactor: remove new policy code from org-layout component * refactor: remove event log use of flag from events component * refactor: remove event log use from collection dialog component * refactor: remove event log use from vault-header component * refactor: remove event-log route logic for org-reporting * refactor: remove logic from org-settings routing * refactor: remove breadcrumbing function and from billing service * refactor: remove ConfigService from DI for billing service * refactor: remove new policy code from policy-edit component * refactor: remove new policy code from policies component * refactor: remove feature flag * fix(Admin Console): revert to use of reactive observables pattern * fix(Admin Console): remove type artifact from reversion --- .../organization-layout.component.html | 7 +- .../layouts/organization-layout.component.ts | 17 - .../manage/events.component.html | 2 +- .../organizations/manage/events.component.ts | 5 - .../policies/policies.component.html | 17 +- .../policies/policies.component.ts | 55 +-- .../policies/policy-edit.component.html | 18 - .../policies/policy-edit.component.ts | 32 +- .../organization-reporting-routing.module.ts | 28 +- .../organization-settings-routing.module.ts | 13 +- .../collection-dialog.component.ts | 25 +- .../vault-header/vault-header.component.ts | 33 +- .../src/services/jslib-services.module.ts | 1 - .../organization-billing.service.ts | 10 - .../organization-billing.service.spec.ts | 321 +++++++++++++----- .../services/organization-billing.service.ts | 35 +- libs/common/src/enums/feature-flag.enum.ts | 2 - 17 files changed, 279 insertions(+), 342 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index be9a85ffe4b..d5570738951 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -55,10 +55,7 @@ ; organizationIsUnmanaged$: Observable; - protected isBreadcrumbEventLogsEnabled$: Observable; protected showSponsoredFamiliesDropdown$: Observable; - protected canShowPoliciesTab$: Observable; protected paymentDetailsPageData$: Observable<{ route: string; @@ -94,9 +92,6 @@ export class OrganizationLayoutComponent implements OnInit { ) {} async ngOnInit() { - this.isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$( - FeatureFlag.PM12276_BreadcrumbEventLogs, - ); document.body.classList.remove("layout_frontend"); this.organization$ = this.route.params.pipe( @@ -141,18 +136,6 @@ export class OrganizationLayoutComponent implements OnInit { this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations)); - this.canShowPoliciesTab$ = this.organization$.pipe( - switchMap((organization) => - this.organizationBillingService - .isBreadcrumbingPoliciesEnabled$(organization) - .pipe( - map( - (isBreadcrumbingEnabled) => isBreadcrumbingEnabled || organization.canManagePolicies, - ), - ), - ), - ); - this.paymentDetailsPageData$ = this.configService .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) .pipe( diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.html b/apps/web/src/app/admin-console/organizations/manage/events.component.html index 02be3476ad5..344e8afef53 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.html @@ -1,4 +1,4 @@ -@let usePlaceHolderEvents = !organization?.useEvents && (isBreadcrumbEventLogsEnabled$ | async); +@let usePlaceHolderEvents = !organization?.useEvents; (); readonly ProductTierType = ProductTierType; - protected isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$( - FeatureFlag.PM12276_BreadcrumbEventLogs, - ); - constructor( private apiService: ApiService, private route: ActivatedRoute, diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index 8eb204b65a4..843d1d18d59 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -1,20 +1,7 @@ - - @let organization = organization$ | async; - @if (isBreadcrumbingEnabled$ | async) { - - } - + + @let organization = organization$ | async; @if (loading) { ; + organization$: Observable; private orgPolicies: PolicyResponse[]; protected policiesEnabledMap: Map = new Map(); - protected isBreadcrumbingEnabled$: Observable; constructor( private route: ActivatedRoute, - private accountService: AccountService, private organizationService: OrganizationService, + private accountService: AccountService, private policyApiService: PolicyApiServiceAbstraction, private policyListService: PolicyListService, - private organizationBillingService: OrganizationBillingServiceAbstraction, private dialogService: DialogService, protected configService: ConfigService, ) {} @@ -62,9 +53,11 @@ export class PoliciesComponent implements OnInit { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + this.organization$ = this.organizationService .organizations$(userId) .pipe(getOrganizationById(this.organizationId)); + this.policies = this.policyListService.getPolicies(); await this.load(); @@ -100,11 +93,7 @@ export class PoliciesComponent implements OnInit { this.orgPolicies.forEach((op) => { this.policiesEnabledMap.set(op.type, op.enabled); }); - this.isBreadcrumbingEnabled$ = this.organization$.pipe( - switchMap((organization) => - this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization), - ), - ); + this.loading = false; } @@ -117,34 +106,8 @@ export class PoliciesComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); - switch (result) { - case PolicyEditDialogResult.Saved: - await this.load(); - break; - case PolicyEditDialogResult.UpgradePlan: - await this.changePlan(await firstValueFrom(this.organization$)); - break; + if (result === PolicyEditDialogResult.Saved) { + await this.load(); } } - - protected readonly CollectionDialogTabType = CollectionDialogTabType; - protected readonly All = All; - - protected async changePlan(organization: Organization) { - const reference = openChangePlanDialog(this.dialogService, { - data: { - organizationId: organization.id, - subscription: null, - productTierType: organization.productTierType, - }, - }); - - const result = await lastValueFrom(reference.closed); - - if (result === ChangePlanDialogResultType.Closed) { - return; - } - - await this.load(); - } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html index 90cfb52e5ad..6573801ad25 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html @@ -1,17 +1,5 @@
- - -
- - - diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts index 2984db67d39..f78ab207020 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts @@ -9,20 +9,12 @@ import { ViewContainerRef, } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { map, Observable, switchMap } from "rxjs"; +import { Observable, map } from "rxjs"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA, @@ -45,7 +37,6 @@ export type PolicyEditDialogData = { // eslint-disable-next-line @bitwarden/platform/no-enums export enum PolicyEditDialogResult { Saved = "saved", - UpgradePlan = "upgrade-plan", } @Component({ selector: "app-policy-edit", @@ -66,22 +57,15 @@ export class PolicyEditComponent implements AfterViewInit { formGroup = this.formBuilder.group({ enabled: [this.enabled], }); - protected organization$: Observable; - protected isBreadcrumbingEnabled$: Observable; - constructor( @Inject(DIALOG_DATA) protected data: PolicyEditDialogData, - private accountService: AccountService, private policyApiService: PolicyApiServiceAbstraction, - private organizationService: OrganizationService, private i18nService: I18nService, private cdr: ChangeDetectorRef, private formBuilder: FormBuilder, private dialogRef: DialogRef, private toastService: ToastService, - private organizationBillingService: OrganizationBillingServiceAbstraction, ) {} - get policy(): BasePolicy { return this.data.policy; } @@ -115,16 +99,6 @@ export class PolicyEditComponent implements AfterViewInit { throw e; } } - this.organization$ = this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.organizationService.organizations$(userId)), - getOrganizationById(this.data.organizationId), - ); - this.isBreadcrumbingEnabled$ = this.organization$.pipe( - switchMap((organization) => - this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization), - ), - ); } submit = async () => { @@ -154,8 +128,4 @@ export class PolicyEditComponent implements AfterViewInit { static open = (dialogService: DialogService, config: DialogConfig) => { return dialogService.open(PolicyEditComponent, config); }; - - protected upgradePlan(): void { - this.dialogRef.close(PolicyEditDialogResult.UpgradePlan); - } } diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index 4c825b26bb2..9e33986b87d 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -1,13 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { inject, NgModule } from "@angular/core"; -import { CanMatchFn, RouterModule, Routes } from "@angular/router"; -import { map } from "rxjs"; +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // eslint-disable-next-line no-restricted-imports import { ExposedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/exposed-passwords-report.component"; @@ -26,11 +23,6 @@ import { EventsComponent } from "../manage/events.component"; import { ReportsHomeComponent } from "./reports-home.component"; -const breadcrumbEventLogsPermission$: CanMatchFn = () => - inject(ConfigService) - .getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs) - .pipe(map((breadcrumbEventLogs) => breadcrumbEventLogs === true)); - const routes: Routes = [ { path: "", @@ -92,24 +84,10 @@ const routes: Routes = [ }, ], }, - // Event routing is temporarily duplicated { path: "events", component: EventsComponent, - canMatch: [breadcrumbEventLogsPermission$], // if this matches, the flag is ON - canActivate: [ - organizationPermissionsGuard( - (org) => (org.canAccessEventLogs && org.useEvents) || org.isOwner, - ), - ], - data: { - titleId: "eventLogs", - }, - }, - { - path: "events", - component: EventsComponent, - canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs)], + canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs || org.isOwner)], data: { titleId: "eventLogs", }, diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index cfec0be531b..a644086628c 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -1,10 +1,8 @@ -import { NgModule, inject } from "@angular/core"; +import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { map } from "rxjs"; import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard"; import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard"; @@ -43,14 +41,7 @@ const routes: Routes = [ { path: "policies", component: PoliciesComponent, - canActivate: [ - organizationPermissionsGuard((o: Organization) => { - const organizationBillingService = inject(OrganizationBillingServiceAbstraction); - return organizationBillingService - .isBreadcrumbingPoliciesEnabled$(o) - .pipe(map((isBreadcrumbingEnabled) => o.canManagePolicies || isBreadcrumbingEnabled)); - }), - ], + canActivate: [organizationPermissionsGuard((org) => org.canManagePolicies)], data: { titleId: "policies", }, diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 33dd4dcaa28..84dd00949cf 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -34,7 +34,6 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { getById } from "@bitwarden/common/platform/misc"; @@ -188,22 +187,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { await this.loadOrg(this.params.organizationId); } - const isBreadcrumbEventLogsEnabled = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs), + this.organizationSelected.setAsyncValidators( + freeOrgCollectionLimitValidator( + this.organizations$, + this.collectionService + .encryptedCollections$(userId) + .pipe(map((collections) => collections ?? [])), + this.i18nService, + ), ); - - if (isBreadcrumbEventLogsEnabled) { - this.organizationSelected.setAsyncValidators( - freeOrgCollectionLimitValidator( - this.organizations$, - this.collectionService - .encryptedCollections$(userId) - .pipe(map((collections) => collections ?? [])), - this.i18nService, - ), - ); - this.formGroup.updateValueAndValidity(); - } + this.formGroup.updateValueAndValidity(); this.organizationSelected.valueChanges .pipe( diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 3dbf7d6c715..216564e6254 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -13,7 +13,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -218,28 +217,22 @@ export class VaultHeaderComponent { } async addCollection(): Promise { - const isBreadcrumbEventLogsEnabled = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs), + const organization = this.organizations?.find( + (org) => org.productTierType === ProductTierType.Free, ); - if (isBreadcrumbEventLogsEnabled) { - const organization = this.organizations?.find( - (org) => org.productTierType === ProductTierType.Free, - ); - - if (this.organizations?.length == 1 && !!organization) { - const collections = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - this.collectionAdminService.collectionAdminViews$(organization.id, userId), - ), + if (this.organizations?.length == 1 && !!organization) { + const collections = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.collectionAdminService.collectionAdminViews$(organization.id, userId), ), - ); - if (collections.length === organization.maxCollections) { - await this.showFreeOrgUpgradeDialog(organization); - return; - } + ), + ); + if (collections.length === organization.maxCollections) { + await this.showFreeOrgUpgradeDialog(organization); + return; } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4920acc1ba4..5066cabd05a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1332,7 +1332,6 @@ const safeProviders: SafeProvider[] = [ I18nServiceAbstraction, OrganizationApiServiceAbstraction, SyncService, - ConfigService, ], }), safeProvider({ diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 113b55465a7..9089c165a33 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -1,7 +1,3 @@ -import { Observable } from "rxjs"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { InitiationPath } from "../../models/request/reference-event.request"; import { PaymentMethodType, PlanType } from "../enums"; @@ -63,10 +59,4 @@ export abstract class OrganizationBillingServiceAbstraction { organizationId: string, subscription: SubscriptionInformation, ): Promise; - - /** - * Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria. - * @param organization - */ - abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable; } diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts index 43457f810d1..1e666e75bb6 100644 --- a/libs/common/src/billing/services/organization-billing.service.spec.ts +++ b/libs/common/src/billing/services/organization-billing.service.spec.ts @@ -1,22 +1,26 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BillingApiServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; -describe("BillingAccountProfileStateService", () => { +import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; +import { EncString } from "../../key-management/crypto/models/enc-string"; +import { OrgKey } from "../../types/key"; +import { PaymentMethodResponse } from "../models/response/payment-method.response"; + +describe("OrganizationBillingService", () => { let apiService: jest.Mocked; let billingApiService: jest.Mocked; let keyService: jest.Mocked; @@ -24,7 +28,6 @@ describe("BillingAccountProfileStateService", () => { let i18nService: jest.Mocked; let organizationApiService: jest.Mocked; let syncService: jest.Mocked; - let configService: jest.Mocked; let sut: OrganizationBillingService; @@ -36,7 +39,6 @@ describe("BillingAccountProfileStateService", () => { i18nService = mock(); organizationApiService = mock(); syncService = mock(); - configService = mock(); sut = new OrganizationBillingService( apiService, @@ -46,7 +48,6 @@ describe("BillingAccountProfileStateService", () => { i18nService, organizationApiService, syncService, - configService, ); }); @@ -54,98 +55,246 @@ describe("BillingAccountProfileStateService", () => { return jest.resetAllMocks(); }); - describe("isBreadcrumbingPoliciesEnabled", () => { - it("returns false when feature flag is disabled", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + describe("getPaymentSource()", () => { + it("given a valid organization id, then it returns a payment source", async () => { + //Arrange + const orgId = "organization-test"; + const paymentMethodResponse = { + paymentSource: { type: PaymentMethodType.Card }, + } as PaymentMethodResponse; + billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse); - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM12276_BreadcrumbEventLogs, + //Act + const returnedPaymentSource = await sut.getPaymentSource(orgId); + + //Assert + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource); + }); + + it("given an invalid organizationId, it should return undefined", async () => { + //Arrange + const orgId = "invalid-id"; + billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null); + + //Act + const returnedPaymentSource = await sut.getPaymentSource(orgId); + + //Assert + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + expect(returnedPaymentSource).toBeUndefined(); + }); + + it("given an API error occurs, then it throws the error", async () => { + // Arrange + const orgId = "error-org"; + billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error")); + + // Act & Assert + await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error"); + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + }); + }); + + describe("purchaseSubscription()", () => { + it("given valid subscription information, then it returns successful response", async () => { + //Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + billingEmail: subscriptionInformation.organization.billingEmail, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.create.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + //Act + const response = await sut.purchaseSubscription(subscriptionInformation); + + //Assert + expect(organizationApiService.create).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); + }); + + it("given organization creation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; + + organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + "Failed to create organization", ); }); - it("returns false when organization belongs to a provider", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: true, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + it("given key generation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - }); + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); - it("returns false when cannot edit subscription", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: false, - productTierType: ProductTierType.Teams, - } as Organization; - - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - }); - - it.each([ - ["Teams", ProductTierType.Teams], - ["TeamsStarter", ProductTierType.TeamsStarter], - ])("returns true when all conditions are met with %s tier", async (_, productTierType) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: productTierType, - } as Organization; - - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(true); - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM12276_BreadcrumbEventLogs, + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + "Key generation failed", ); }); - it("returns false when product tier is not supported", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Enterprise, - } as Organization; + it("given an invalid plan type, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: -1 as unknown as PlanType }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow(); + }); + }); + + describe("purchaseSubscriptionNoPaymentMethod()", () => { + it("given valid subscription information, then it returns successful response", async () => { + //Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + plan: { type: subscriptionInformation.plan.type }, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); + + //Act + const response = await sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation); + + //Assert + expect(organizationApiService.createWithoutPayment).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); }); - it("handles all conditions false correctly", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: true, - canEditSubscription: false, - productTierType: ProductTierType.Free, - } as Organization; + it("given organization creation fails without payment method, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); + organizationApiService.createWithoutPayment.mockRejectedValue(new Error("Creation failed")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); + + await expect( + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + ).rejects.toThrow("Creation failed"); }); - it("verifies feature flag is only called once", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + it("given key generation fails, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; - await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1); + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + + await expect( + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + ).rejects.toThrow("Key generation failed"); + }); + }); + + describe("startFree()", () => { + it("given valid free plan information, then it creates a free organization", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + billingEmail: subscriptionInformation.organization.billingEmail, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.create.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + //Act + const response = await sut.startFree(subscriptionInformation); + + //Assert + expect(organizationApiService.create).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); + }); + + it("given key generation fails, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + + await expect(sut.startFree(subscriptionInformation)).rejects.toThrow("Key generation failed"); + }); + + it("given organization creation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + // Act & Assert + await expect(sut.startFree(subscriptionInformation)).rejects.toThrow( + "Failed to create organization", + ); }); }); }); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index aaf22815404..e4fe2f9a6bd 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -1,10 +1,5 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable, of, switchMap } from "rxjs"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -27,7 +22,7 @@ import { PlanInformation, SubscriptionInformation, } from "../abstractions"; -import { PlanType, ProductTierType } from "../enums"; +import { PlanType } from "../enums"; import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; import { PaymentSourceResponse } from "../models/response/payment-source.response"; @@ -47,12 +42,11 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs private i18nService: I18nService, private organizationApiService: OrganizationApiService, private syncService: SyncService, - private configService: ConfigService, ) {} async getPaymentSource(organizationId: string): Promise { const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId); - return paymentMethod.paymentSource; + return paymentMethod?.paymentSource; } async purchaseSubscription(subscription: SubscriptionInformation): Promise { @@ -229,29 +223,4 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs this.setPaymentInformation(request, subscription.payment); await this.billingApiService.restartSubscription(organizationId, request); } - - isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable { - if (organization === null || organization === undefined) { - return of(false); - } - - return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe( - switchMap((featureFlagEnabled) => { - if (!featureFlagEnabled) { - return of(false); - } - - if (organization.isProviderUser || !organization.canEditSubscription) { - return of(false); - } - - const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter]; - const isSupportedProduct = supportedProducts.some( - (product) => product === organization.productTierType, - ); - - return of(isSupportedProduct); - }), - ); - } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 262b31e624f..00bd8c4c207 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -23,7 +23,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", UseOrganizationWarningsService = "use-organization-warnings-service", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", @@ -95,7 +94,6 @@ export const DefaultFeatureFlagValue = { /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.UseOrganizationWarningsService]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, From 281918a7b9911688dad536a93b392bea33b169a7 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 3 Sep 2025 17:01:45 -0400 Subject: [PATCH 15/25] feat(inactive-user-server-notification): [PM-25130] Inactive User Server Notify (#16151) * feat(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Adds in tests and feature for notifying inactive users. * feat(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Added feature flag. * fix(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Implemented trackedMerge. --- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/platform/misc/rxjs-operators.ts | 89 ++++- ...ult-server-notifications.multiuser.spec.ts | 313 ++++++++++++++++++ ...ault-server-notifications.service.spec.ts} | 24 +- .../default-server-notifications.service.ts | 79 ++++- 5 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts rename libs/common/src/platform/server-notifications/internal/{default-notifications.service.spec.ts => default-server-notifications.service.spec.ts} (94%) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 00bd8c4c207..d5f1f5f4fd7 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -50,6 +50,7 @@ export enum FeatureFlag { /* Platform */ IpcChannelFramework = "ipc-channel-framework", + InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users", PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked", } @@ -107,6 +108,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.InactiveUserServerNotification]: FALSE, [FeatureFlag.PushNotificationsWhenLocked]: FALSE, } satisfies Record; diff --git a/libs/common/src/platform/misc/rxjs-operators.ts b/libs/common/src/platform/misc/rxjs-operators.ts index b3c4423c36f..6c767d3458d 100644 --- a/libs/common/src/platform/misc/rxjs-operators.ts +++ b/libs/common/src/platform/misc/rxjs-operators.ts @@ -1,4 +1,4 @@ -import { map } from "rxjs"; +import { map, Observable, OperatorFunction, Subscription } from "rxjs"; /** * An rxjs operator that extracts an object by ID from an array of objects. @@ -19,3 +19,90 @@ export const getByIds = (ids: TId[]) => { return objects.filter((o) => o.id && idSet.has(o.id)); }); }; + +/** + * A merge-like operator that takes a Set of primitives and tracks if they've been + * seen before. + * + * An emitted set that looks like `["1", "2"]` will call selector and subscribe to the resulting + * observable for both `"1"` and `"2"` but if the next emission contains just `["1"]` then the + * subscription created for `"2"` will be unsubscribed from and the observable for `"1"` will be + * left alone. If the following emission a set like `["1", "2", "3"]` then the subscription for + * `"1"` is still left alone, `"2"` has a selector called for it again, and `"3"` has a selector + * called for it the first time. If an empty set is emitted then all items are unsubscribed from. + * + * Since this operator will keep track of an observable for `n` number of items given to it. It is + * smartest to only use this on sets that you know will only get so large. + * + * *IMPORTANT NOTE* + * This observable may not be super friendly to very quick emissions/near parallel execution. + */ +export function trackedMerge( + selector: (value: T) => Observable, +): OperatorFunction, E> { + return (source: Observable>) => { + // Setup a Map to track all inner subscriptions + const tracked: Map = new Map(); + + const cleanupTracked = () => { + for (const [, trackedSub] of tracked.entries()) { + trackedSub.unsubscribe(); + } + tracked.clear(); + }; + + return new Observable((subscriber) => { + const sourceSub = source.subscribe({ + next: (values) => { + // Loop through the subscriptions we are tracking, if the new list + // doesn't have any of those values, we should clean them up. + for (const value of tracked.keys()) { + if (!values.has(value)) { + // Tracked item is no longer in the list, cleanup + tracked.get(value)?.unsubscribe(); + tracked.delete(value); + continue; + } + + // We are already tracking something for this key, remove it + values.delete(value); + } + + for (const newKey of values.keys()) { + // These are new entries, create and track subscription for them + tracked.set( + newKey, + /* eslint-disable-next-line rxjs/no-nested-subscribe */ + selector(newKey).subscribe({ + next: (innerValue) => { + subscriber.next(innerValue); + }, + error: (err: unknown) => { + // TODO: Do I need to call cleanupTracked or will calling error run my teardown logic below? + subscriber.error(err); + }, + complete: () => { + tracked.delete(newKey); + }, + }), + ); + } + }, + error: (err: unknown) => { + // TODO: Do I need to call cleanupTracked or will calling error run my teardown logic below? + subscriber.error(err); + }, + complete: () => { + // TODO: Do I need to call cleanupTracked or will calling complete run my teardown logic below? + cleanupTracked(); + subscriber.complete(); + }, + }); + + return () => { + cleanupTracked(); + sourceSub.unsubscribe(); + }; + }); + }; +} diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts new file mode 100644 index 00000000000..a70623783dc --- /dev/null +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -0,0 +1,313 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { LogoutReason } from "@bitwarden/auth/common"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + +import { AccountService } from "../../../auth/abstractions/account.service"; +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { NotificationType } from "../../../enums"; +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { AppIdService } from "../../abstractions/app-id.service"; +import { ConfigService } from "../../abstractions/config/config.service"; +import { Environment, EnvironmentService } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; + +import { DefaultServerNotificationsService } from "./default-server-notifications.service"; +import { SignalRConnectionService } from "./signalr-connection.service"; +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; + +describe("DefaultServerNotificationsService (multi-user)", () => { + let syncService: any; + let appIdService: MockProxy; + let environmentConfigurationService: MockProxy; + let userLogoutCallback: jest.Mock, [logoutReason: LogoutReason, userId: UserId]>; + let messagingService: MockProxy; + let accountService: MockProxy; + let signalRNotificationConnectionService: MockProxy; + let authService: MockProxy; + let webPushNotificationConnectionService: MockProxy; + let authRequestAnsweringService: MockProxy; + let configService: MockProxy; + + let activeUserAccount$: BehaviorSubject>; + let userAccounts$: BehaviorSubject>; + + let environmentConfiguration$: BehaviorSubject; + + let authenticationStatusByUser: Map>; + let webPushSupportStatusByUser: Map< + UserId, + BehaviorSubject< + { type: "supported"; service: WebPushConnector } | { type: "not-supported"; reason: string } + > + >; + + let connectionSubjectByUser: Map>; + let defaultServerNotificationsService: DefaultServerNotificationsService; + + const mockUserId1 = "user1" as UserId; + const mockUserId2 = "user2" as UserId; + + beforeEach(() => { + syncService = { + fullSync: jest.fn().mockResolvedValue(undefined), + syncUpsertCipher: jest.fn().mockResolvedValue(undefined), + syncDeleteCipher: jest.fn().mockResolvedValue(undefined), + syncUpsertFolder: jest.fn().mockResolvedValue(undefined), + syncDeleteFolder: jest.fn().mockResolvedValue(undefined), + syncUpsertSend: jest.fn().mockResolvedValue(undefined), + syncDeleteSend: jest.fn().mockResolvedValue(undefined), + }; + + appIdService = mock(); + appIdService.getAppId.mockResolvedValue("app-id"); + + environmentConfigurationService = mock(); + environmentConfiguration$ = new BehaviorSubject({ + getNotificationsUrl: () => "http://test.example.com", + } as Environment); + environmentConfigurationService.environment$ = environmentConfiguration$ as any; + // Ensure user-scoped environment lookups return the same test environment stream + environmentConfigurationService.getEnvironment$.mockImplementation( + (_userId: UserId) => environmentConfiguration$.asObservable() as any, + ); + + userLogoutCallback = jest.fn, [LogoutReason, UserId]>(); + + messagingService = mock(); + + accountService = mock(); + activeUserAccount$ = new BehaviorSubject>( + null, + ); + accountService.activeAccount$ = activeUserAccount$.asObservable(); + userAccounts$ = new BehaviorSubject>({} as any); + accountService.accounts$ = userAccounts$.asObservable(); + + signalRNotificationConnectionService = mock(); + connectionSubjectByUser = new Map(); + signalRNotificationConnectionService.connect$.mockImplementation( + (userId: UserId, _url: string) => { + if (!connectionSubjectByUser.has(userId)) { + connectionSubjectByUser.set(userId, new Subject()); + } + return connectionSubjectByUser.get(userId)!.asObservable(); + }, + ); + + authService = mock(); + authenticationStatusByUser = new Map(); + authService.authStatusFor$.mockImplementation((userId: UserId) => { + if (!authenticationStatusByUser.has(userId)) { + authenticationStatusByUser.set( + userId, + new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + } + return authenticationStatusByUser.get(userId)!.asObservable(); + }); + + webPushNotificationConnectionService = mock(); + webPushSupportStatusByUser = new Map(); + webPushNotificationConnectionService.supportStatus$.mockImplementation((userId: UserId) => { + if (!webPushSupportStatusByUser.has(userId)) { + webPushSupportStatusByUser.set( + userId, + new BehaviorSubject({ type: "not-supported", reason: "init" } as any), + ); + } + return webPushSupportStatusByUser.get(userId)!.asObservable(); + }); + + authRequestAnsweringService = mock(); + + configService = mock(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + const flagValueByFlag: Partial> = { + [FeatureFlag.InactiveUserServerNotification]: true, + }; + return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; + }); + + defaultServerNotificationsService = new DefaultServerNotificationsService( + mock(), + syncService, + appIdService, + environmentConfigurationService, + userLogoutCallback, + messagingService, + accountService, + signalRNotificationConnectionService, + authService, + webPushNotificationConnectionService, + authRequestAnsweringService, + configService, + ); + }); + + function setActiveUserAccount(userId: UserId | null) { + if (userId == null) { + activeUserAccount$.next(null); + } else { + activeUserAccount$.next({ + id: userId, + email: "email", + name: "Test Name", + emailVerified: true, + }); + } + } + + function addUserAccount(userId: UserId) { + const currentAccounts = (userAccounts$.getValue() as Record) ?? {}; + userAccounts$.next({ + ...currentAccounts, + [userId]: { email: "email", name: "Test Name", emailVerified: true }, + } as any); + } + + function setUserUnlocked(userId: UserId) { + if (!authenticationStatusByUser.has(userId)) { + authenticationStatusByUser.set( + userId, + new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + } + authenticationStatusByUser.get(userId)!.next(AuthenticationStatus.Unlocked); + } + + function setWebPushConnectorForUser(userId: UserId) { + const webPushConnector = mock(); + const notificationSubject = new Subject(); + webPushConnector.notifications$ = notificationSubject.asObservable(); + if (!webPushSupportStatusByUser.has(userId)) { + webPushSupportStatusByUser.set( + userId, + new BehaviorSubject({ type: "supported", service: webPushConnector } as any), + ); + } else { + webPushSupportStatusByUser + .get(userId)! + .next({ type: "supported", service: webPushConnector } as any); + } + return { webPushConnector, notificationSubject } as const; + } + + it("merges notification streams from multiple users", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + const user1WebPush = setWebPushConnectorForUser(mockUserId1); + const user2WebPush = setWebPushConnectorForUser(mockUserId2); + + const twoNotifications = firstValueFrom( + defaultServerNotificationsService.notifications$.pipe(bufferCount(2)), + ); + + user1WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + user2WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderDelete }), + ); + + const notificationResults = await twoNotifications; + expect(notificationResults.length).toBe(2); + const [notification1, userA] = notificationResults[0]; + const [notification2, userB] = notificationResults[1]; + expect(userA === mockUserId1 || userA === mockUserId2).toBe(true); + expect(userB === mockUserId1 || userB === mockUserId2).toBe(true); + expect([NotificationType.SyncFolderCreate, NotificationType.SyncFolderDelete]).toContain( + notification1.type, + ); + expect([NotificationType.SyncFolderCreate, NotificationType.SyncFolderDelete]).toContain( + notification2.type, + ); + }); + + it("processes allowed multi-user notifications for non-active users (AuthRequest)", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + // Force SignalR path for user2 + if (!webPushSupportStatusByUser.has(mockUserId2)) { + webPushSupportStatusByUser.set( + mockUserId2, + new BehaviorSubject({ type: "not-supported", reason: "test" } as any), + ); + } else { + webPushSupportStatusByUser + .get(mockUserId2)! + .next({ type: "not-supported", reason: "test" } as any); + } + + // TODO: When PM-14943 goes in, uncomment + // authRequestAnsweringService.receivedPendingAuthRequest.mockResolvedValue(undefined as any); + + const subscription = defaultServerNotificationsService.startListening(); + + // Emit via SignalR connect$ for user2 + connectionSubjectByUser.get(mockUserId2)!.next({ + type: "ReceiveMessage", + message: new NotificationResponse({ + type: NotificationType.AuthRequest, + payload: { id: "auth-id-2", userId: mockUserId2 }, + }), + }); + + // allow async queue to drain + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { + notificationId: "auth-id-2", + }); + + // TODO: When PM-14943 goes in, uncomment + // expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith( + // mockUserId2, + // "auth-id-2", + // ); + + subscription.unsubscribe(); + }); + + it("does not process restricted notification types for non-active users", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + const user1WebPush = setWebPushConnectorForUser(mockUserId1); + const user2WebPush = setWebPushConnectorForUser(mockUserId2); + + const subscription = defaultServerNotificationsService.startListening(); + + // Emit a folder create for non-active user (should be ignored) + user2WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + // Emit a folder create for active user (should be processed) + user1WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(syncService.syncUpsertFolder).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); +}); diff --git a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts similarity index 94% rename from libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts rename to libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index 2d12027e19f..a7b608f5b56 100644 --- a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -1,11 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs"; +import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { awaitAsync } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -45,6 +44,7 @@ describe("NotificationsService", () => { let configService: MockProxy; let activeAccount: BehaviorSubject>; + let accounts: BehaviorSubject>; let environment: BehaviorSubject>; @@ -73,21 +73,23 @@ describe("NotificationsService", () => { configService = mock(); // For these tests, use the active-user implementation (feature flag disabled) - configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { - const flagValueByFlag: Partial> = { - [FeatureFlag.PushNotificationsWhenLocked]: true, - }; - return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; - }); + configService.getFeatureFlag$.mockImplementation(() => of(true)); activeAccount = new BehaviorSubject>(null); accountService.activeAccount$ = activeAccount.asObservable(); + accounts = new BehaviorSubject>({} as any); + accountService.accounts$ = accounts.asObservable(); + environment = new BehaviorSubject>({ getNotificationsUrl: () => "https://notifications.bitwarden.com", } as Environment); environmentService.environment$ = environment; + // Ensure user-scoped environment lookups return the same test environment stream + environmentService.getEnvironment$.mockImplementation( + (_userId: UserId) => environment.asObservable() as any, + ); authStatusGetter = Matrix.autoMockMethod( authService.authStatusFor$, @@ -130,8 +132,14 @@ describe("NotificationsService", () => { function emitActiveUser(userId: UserId | null) { if (userId == null) { activeAccount.next(null); + accounts.next({} as any); } else { activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true }); + const current = (accounts.getValue() as Record) ?? {}; + accounts.next({ + ...current, + [userId]: { email: "email", name: "Test Name", emailVerified: true }, + } as any); } } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 89e88d645c6..e8ac93dc61f 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -17,8 +17,9 @@ import { import { LogoutReason } from "@bitwarden/auth/common"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { trackedMerge } from "@bitwarden/common/platform/misc"; -import { AccountService } from "../../../auth/abstractions/account.service"; +import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { NotificationType } from "../../../enums"; @@ -43,6 +44,10 @@ import { WebPushConnectionService } from "./webpush-connection.service"; export const DISABLED_NOTIFICATIONS_URL = "http://-"; +export const AllowedMultiUserNotificationTypes = new Set([ + NotificationType.AuthRequest, +]); + export class DefaultServerNotificationsService implements ServerNotificationsService { notifications$: Observable; @@ -62,21 +67,48 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, private readonly configService: ConfigService, ) { - this.notifications$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), - distinctUntilChanged(), - switchMap((activeAccountId) => { - if (activeAccountId == null) { - // We don't emit server-notifications for inactive accounts currently - return EMPTY; - } + this.notifications$ = this.configService + .getFeatureFlag$(FeatureFlag.InactiveUserServerNotification) + .pipe( + distinctUntilChanged(), + switchMap((inactiveUserServerNotificationEnabled) => { + if (inactiveUserServerNotificationEnabled) { + return this.accountService.accounts$.pipe( + map((accounts: Record): Set => { + const validUserIds = Object.entries(accounts) + .filter( + ([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified, + ) + .map(([userId, _]) => userId as UserId); + return new Set(validUserIds); + }), + trackedMerge((id: UserId) => { + return this.userNotifications$(id as UserId).pipe( + map( + (notification: NotificationResponse) => [notification, id as UserId] as const, + ), + ); + }), + ); + } - return this.userNotifications$(activeAccountId).pipe( - map((notification) => [notification, activeAccountId] as const), - ); - }), - share(), // Multiple subscribers should only create a single connection to the server - ); + return this.accountService.activeAccount$.pipe( + map((account) => account?.id), + distinctUntilChanged(), + switchMap((activeAccountId) => { + if (activeAccountId == null) { + // We don't emit server-notifications for inactive accounts currently + return EMPTY; + } + + return this.userNotifications$(activeAccountId).pipe( + map((notification) => [notification, activeAccountId] as const), + ); + }), + ); + }), + share(), // Multiple subscribers should only create a single connection to the server + ); } /** @@ -84,7 +116,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer * @param userId The user id of the user to get the push server notifications for. */ private userNotifications$(userId: UserId) { - return this.environmentService.environment$.pipe( + return this.environmentService.getEnvironment$(userId).pipe( map((env) => env.getNotificationsUrl()), distinctUntilChanged(), switchMap((notificationsUrl) => { @@ -171,6 +203,21 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer return; } + if ( + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification), + ) + ) { + const activeAccountId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const isActiveUser = activeAccountId === userId; + if (!isActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) { + return; + } + } + switch (notification.type) { case NotificationType.SyncCipherCreate: case NotificationType.SyncCipherUpdate: From 54eb54483f61590121e43e978c5cb88dcaa9629b Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:16:56 -0500 Subject: [PATCH 16/25] [PM-24797] Hidden custom fields for new ciphers (#16198) * allow for hidden fields to be moved/edited when adding a cipher * disable drag when the field cannot be edited --- .../components/custom-fields/custom-fields.component.html | 1 + .../components/custom-fields/custom-fields.component.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index 78c784b083d..4e3d9fb17d6 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -22,6 +22,7 @@ }" [attr.data-testid]="field.value.name + '-entry'" cdkDrag + [cdkDragDisabled]="!canEdit(field.value.type)" #customFieldRow > diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts index e3612e75a1b..12e83b052bd 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -150,7 +150,9 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit { canEdit(type: FieldType): boolean { return ( !this.isPartialEdit && - (type !== FieldType.Hidden || this.cipherFormContainer.originalCipherView?.viewPassword) + (type !== FieldType.Hidden || + this.cipherFormContainer.originalCipherView === null || + this.cipherFormContainer.originalCipherView.viewPassword) ); } From b957a0c28f69ee22cb32ebfe4c9d52361322d90b Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 4 Sep 2025 08:21:50 +0200 Subject: [PATCH 17/25] [PM-24646] Add licensed browser builds (#16252) * feat: create separate bit licensed browser * feat: refactor webpack config * fix: mv2 build not working * feat: add bit versions of all commands * feat: add bit CI builds * fix: scss missing from build --- .github/workflows/build-browser.yml | 79 +++- apps/browser/package.json | 30 +- apps/browser/src/popup/app.component.html | 20 + apps/browser/src/popup/app.component.ts | 23 +- apps/browser/src/popup/main.ts | 7 +- apps/browser/src/popup/scss/index.ts | 4 + apps/browser/webpack.base.js | 443 ++++++++++++++++++ apps/browser/webpack.config.js | 421 +---------------- bitwarden_license/bit-browser/jest.config.js | 22 + .../src/background/main.background.ts | 9 + .../bit-browser/src/platform/background.ts | 7 + .../src/popup/app-routing.module.ts | 10 + .../bit-browser/src/popup/app.component.ts | 14 + .../bit-browser/src/popup/app.module.ts | 39 ++ .../bit-browser/src/popup/main.ts | 26 + bitwarden_license/bit-browser/tsconfig.json | 20 + .../bit-browser/tsconfig.spec.json | 8 + .../bit-browser/webpack.config.js | 13 + jest.config.js | 3 +- tsconfig.base.json | 1 + 20 files changed, 738 insertions(+), 461 deletions(-) create mode 100644 apps/browser/src/popup/app.component.html create mode 100644 apps/browser/src/popup/scss/index.ts create mode 100644 apps/browser/webpack.base.js create mode 100644 bitwarden_license/bit-browser/jest.config.js create mode 100644 bitwarden_license/bit-browser/src/background/main.background.ts create mode 100644 bitwarden_license/bit-browser/src/platform/background.ts create mode 100644 bitwarden_license/bit-browser/src/popup/app-routing.module.ts create mode 100644 bitwarden_license/bit-browser/src/popup/app.component.ts create mode 100644 bitwarden_license/bit-browser/src/popup/app.module.ts create mode 100644 bitwarden_license/bit-browser/src/popup/main.ts create mode 100644 bitwarden_license/bit-browser/tsconfig.json create mode 100644 bitwarden_license/bit-browser/tsconfig.spec.json create mode 100644 bitwarden_license/bit-browser/webpack.config.js diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 43661d50910..823cb7e25e0 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -123,11 +123,20 @@ jobs: build-source: - name: Build browser source + name: Build browser source - ${{matrix.license_type.readable}} runs-on: ubuntu-22.04 needs: - setup - locales-test + strategy: + matrix: + license_type: + - include_bitwarden_license_folder: false + archive_name_prefix: "" + readable: "open source license" + - include_bitwarden_license_folder: true + archive_name_prefix: "bit-" + readable: "commercial license" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -166,6 +175,12 @@ jobs: mkdir -p browser-source/apps/browser cp -r apps/browser/* browser-source/apps/browser + # Copy bitwarden_license/bit-browser to the Browser source directory + if [[ ${{matrix.license_type.include_bitwarden_license_folder}} == "true" ]]; then + mkdir -p browser-source/bitwarden_license/bit-browser + cp -r bitwarden_license/bit-browser/* browser-source/bitwarden_license/bit-browser + fi + # Copy libs to Browser source directory mkdir browser-source/libs cp -r libs/* browser-source/libs @@ -175,13 +190,13 @@ jobs: - name: Upload browser source uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: browser-source-${{ env._BUILD_NUMBER }}.zip + name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip if-no-files-found: error build: - name: Build + name: Build ${{ matrix.browser.name }} - ${{ matrix.license_type.readable }} runs-on: ubuntu-22.04 needs: - setup @@ -192,25 +207,38 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} strategy: matrix: - include: + license_type: + - build_prefix: "" + artifact_prefix: "" + source_archive_name_prefix: "" + archive_name_prefix: "" + npm_command_prefix: "dist:" + readable: "open source license" + - build_prefix: "bit-" + artifact_prefix: "bit-" + source_archive_name_prefix: "bit-" + archive_name_prefix: "bit-" + npm_command_prefix: "dist:bit:" + readable: "commercial license" + browser: - name: "chrome" - npm_command: "dist:chrome" + npm_command_suffix: "chrome" archive_name: "dist-chrome.zip" artifact_name: "dist-chrome-MV3" - name: "edge" - npm_command: "dist:edge" + npm_command_suffix: "edge" archive_name: "dist-edge.zip" artifact_name: "dist-edge-MV3" - name: "firefox" - npm_command: "dist:firefox" + npm_command_suffix: "firefox" archive_name: "dist-firefox.zip" artifact_name: "dist-firefox" - name: "firefox-mv3" - npm_command: "dist:firefox:mv3" + npm_command_suffix: "firefox:mv3" archive_name: "dist-firefox.zip" artifact_name: "DO-NOT-USE-FOR-PROD-dist-firefox-MV3" - name: "opera-mv3" - npm_command: "dist:opera:mv3" + npm_command_suffix: "opera:mv3" archive_name: "dist-opera.zip" artifact_name: "dist-opera-MV3" steps: @@ -234,7 +262,7 @@ jobs: - name: Download browser source uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: - name: browser-source-${{ env._BUILD_NUMBER }}.zip + name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip - name: Unzip browser source artifact run: | @@ -264,7 +292,7 @@ jobs: run: npm link ../sdk-internal - name: Check source file size - if: ${{ startsWith(matrix.name, 'firefox') }} + if: ${{ startsWith(matrix.browser.name, 'firefox') }} run: | # Declare variable as indexed array declare -a FILES @@ -287,19 +315,19 @@ jobs: fi - name: Build extension - run: npm run ${{ matrix.npm_command }} + run: npm run ${{matrix.license_type.npm_command_prefix}}${{ matrix.browser.npm_command_suffix }} working-directory: browser-source/apps/browser - name: Upload extension artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: ${{ matrix.artifact_name }}-${{ env._BUILD_NUMBER }}.zip - path: browser-source/apps/browser/dist/${{ matrix.archive_name }} + name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip + path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }} if-no-files-found: error build-safari: - name: Build Safari + name: Build Safari - ${{ matrix.license_type.readable }} runs-on: macos-13 permissions: contents: read @@ -308,6 +336,19 @@ jobs: - setup - locales-test if: ${{ needs.setup.outputs.has_secrets == 'true' }} + strategy: + matrix: + license_type: + - build_prefix: "" + artifact_prefix: "" + archive_name_prefix: "" + npm_command_prefix: "dist:" + readable: "open source license" + - build_prefix: "bit-" + artifact_prefix: "bit-" + archive_name_prefix: "bit-" + npm_command_prefix: "dist:bit:" + readable: "commercial license" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -433,21 +474,21 @@ jobs: npm link ../sdk-internal - name: Build Safari extension - run: npm run dist:safari + run: npm run ${{matrix.license_type.npm_command_prefix}}safari working-directory: apps/browser - name: Zip Safari build artifact run: | cd apps/browser/dist - zip dist-safari.zip ./Safari/**/build/Release/safari.appex -r + zip ${{matrix.license_type.archive_name_prefix }}dist-safari.zip ./Safari/**/build/Release/safari.appex -r pwd ls -la - name: Upload Safari artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: dist-safari-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-safari.zip + name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip + path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip if-no-files-found: error crowdin-push: diff --git a/apps/browser/package.json b/apps/browser/package.json index 3cfc4377227..bfa152f236a 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -3,32 +3,58 @@ "version": "2025.8.2", "scripts": { "build": "npm run build:chrome", + "build:bit": "npm run build:bit:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:watch": "npm run build:watch:chrome", "build:watch:chrome": "npm run build:chrome -- --watch", + "build:bit:watch:chrome": "npm run build:bit:chrome -- --watch", "build:watch:edge": "npm run build:edge -- --watch", + "build:bit:watch:edge": "npm run build:bit:edge -- --watch", "build:watch:firefox": "npm run build:firefox -- --watch", + "build:bit:watch:firefox": "npm run build:bit:firefox -- --watch", "build:watch:opera": "npm run build:opera -- --watch", + "build:bit:watch:opera": "npm run build:bit:opera -- --watch", "build:watch:safari": "npm run build:safari -- --watch", - "build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:firefox -- --watch", - "build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:safari -- --watch", + "build:bit:watch:safari": "npm run build:bit:safari -- --watch", + "build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:firefox", + "build:bit:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:firefox", + "build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:safari", + "build:bit:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:safari", "build:prod:chrome": "cross-env NODE_ENV=production npm run build:chrome", + "build:bit:prod:chrome": "cross-env NODE_ENV=production npm run build:bit:chrome", "build:prod:edge": "cross-env NODE_ENV=production npm run build:edge", + "build:bit:prod:edge": "cross-env NODE_ENV=production npm run build:bit:edge", "build:prod:firefox": "cross-env NODE_ENV=production npm run build:firefox", + "build:bit:prod:firefox": "cross-env NODE_ENV=production npm run build:bit:firefox", "build:prod:opera": "cross-env NODE_ENV=production npm run build:opera", + "build:bit:prod:opera": "cross-env NODE_ENV=production npm run build:bit:opera", "build:prod:safari": "cross-env NODE_ENV=production npm run build:safari", + "build:bit:prod:safari": "cross-env NODE_ENV=production npm run build:bit:safari", "dist:chrome": "npm run build:prod:chrome && mkdir -p dist && ./scripts/compress.sh dist-chrome.zip", + "dist:bit:chrome": "npm run build:bit:prod:chrome && mkdir -p dist && ./scripts/compress.sh bit-dist-chrome.zip", "dist:edge": "npm run build:prod:edge && mkdir -p dist && ./scripts/compress.sh dist-edge.zip", + "dist:bit:edge": "npm run build:bit:prod:edge && mkdir -p dist && ./scripts/compress.sh bit-dist-edge.zip", "dist:firefox": "npm run build:prod:firefox && mkdir -p dist && ./scripts/compress.sh dist-firefox.zip", + "dist:bit:firefox": "npm run build:bit:prod:firefox && mkdir -p dist && ./scripts/compress.sh bit-dist-firefox.zip", "dist:opera": "npm run build:prod:opera && mkdir -p dist && ./scripts/compress.sh dist-opera.zip", + "dist:bit:opera": "npm run build:bit:prod:opera && mkdir -p dist && ./scripts/compress.sh bit-dist-opera.zip", "dist:safari": "npm run build:prod:safari && ./scripts/package-safari.ps1", + "dist:bit:safari": "npm run build:bit:prod:safari && ./scripts/package-safari.ps1", "dist:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:firefox", + "dist:bit:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:firefox", "dist:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:opera", + "dist:bit:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:opera", "dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari", + "dist:bit:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:safari", "test": "jest", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll", diff --git a/apps/browser/src/popup/app.component.html b/apps/browser/src/popup/app.component.html new file mode 100644 index 00000000000..3d81354ca35 --- /dev/null +++ b/apps/browser/src/popup/app.component.html @@ -0,0 +1,20 @@ +@if (showSdkWarning | async) { + +} @else { +
+ +
+ +} diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index ee75dbaf7af..4f46f889eaa 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -57,28 +57,7 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn selector: "app-root", styles: [], animations: [routerTransition], - template: ` - @if (showSdkWarning | async) { -
- - {{ "wasmNotSupported" | i18n }} - - {{ "learnMore" | i18n }} - - -
- } @else { -
- -
- - } - `, + templateUrl: "app.component.html", standalone: false, }) export class AppComponent implements OnInit, OnDestroy { diff --git a/apps/browser/src/popup/main.ts b/apps/browser/src/popup/main.ts index bb975f48e5d..fa6a07d031a 100644 --- a/apps/browser/src/popup/main.ts +++ b/apps/browser/src/popup/main.ts @@ -4,13 +4,10 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { PopupSizeService } from "../platform/popup/layout/popup-size.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./scss/popup.scss"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./scss/tailwind.css"); - import { AppModule } from "./app.module"; +import "./scss"; + // We put these first to minimize the delay in window changing. PopupSizeService.initBodyWidthFromLocalStorage(); // Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861] diff --git a/apps/browser/src/popup/scss/index.ts b/apps/browser/src/popup/scss/index.ts new file mode 100644 index 00000000000..abb62fa0dd2 --- /dev/null +++ b/apps/browser/src/popup/scss/index.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("./popup.scss"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("./tailwind.css"); diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js new file mode 100644 index 00000000000..872da6600b4 --- /dev/null +++ b/apps/browser/webpack.base.js @@ -0,0 +1,443 @@ +const path = require("path"); +const webpack = require("webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const TerserPlugin = require("terser-webpack-plugin"); +const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin"); +const configurator = require("./config/config"); +const manifest = require("./webpack/manifest"); +const AngularCheckPlugin = require("./webpack/angular-check"); + +module.exports.getEnv = function getEnv() { + const ENV = (process.env.ENV = process.env.NODE_ENV); + const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; + const browser = process.env.BROWSER ?? "chrome"; + + return { ENV, manifestVersion, browser }; +}; + +/** + * @param {{ + * configName: string; + * popup: { + * entry: string; + * entryModule: string; + * }; + * background: { + * entry: string; + * }; + * tsConfig: string; + * additionalEntries?: { [outputPath: string]: string } + * }} params - The input parameters for building the config. + */ +module.exports.buildConfig = function buildConfig(params) { + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = "development"; + } + + const { ENV, manifestVersion, browser } = module.exports.getEnv(); + + console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`); + + const envConfig = configurator.load(ENV); + configurator.log(envConfig); + + const moduleRules = [ + { + test: /\.(html)$/, + loader: "html-loader", + }, + { + test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading.svg/, + generator: { + filename: "popup/fonts/[name].[contenthash][ext]", + }, + type: "asset/resource", + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/, + generator: { + filename: "popup/images/[name][ext]", + }, + type: "asset/resource", + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "postcss-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.[cm]?js$/, + use: [ + { + loader: "babel-loader", + options: { + configFile: "../../babel.config.json", + cacheDirectory: ENV === "development", + compact: ENV !== "development", + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + loader: "@ngtools/webpack", + }, + ]; + + const requiredPlugins = [ + new webpack.DefinePlugin({ + "process.env": { + ENV: JSON.stringify(ENV), + }, + }), + new webpack.EnvironmentPlugin({ + FLAGS: envConfig.flags, + DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {}, + }), + ]; + + const plugins = [ + new HtmlWebpackPlugin({ + template: "./src/popup/index.ejs", + filename: "popup/index.html", + chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], + browser: browser, + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/notification/bar.html", + filename: "notification/bar.html", + chunks: ["notification/bar"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + filename: "overlay/menu-button.html", + chunks: ["overlay/menu-button"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + filename: "overlay/menu-list.html", + chunks: ["overlay/menu-list"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + filename: "overlay/menu.html", + chunks: ["overlay/menu"], + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", + to: "manifest.json", + transform: manifest.transform(browser), + }, + { from: "./src/managed_schema.json", to: "managed_schema.json" }, + { from: "./src/_locales", to: "_locales" }, + { from: "./src/images", to: "images" }, + { from: "./src/popup/images", to: "popup/images" }, + { from: "./src/autofill/content/autofill.css", to: "content" }, + ], + }), + new MiniCssExtractPlugin({ + filename: "[name].css", + chunkFilename: "chunk-[id].css", + }), + new AngularWebpackPlugin({ + tsconfig: params.tsConfig, + entryModule: params.popup.entryModule, + sourceMap: true, + }), + new webpack.ProvidePlugin({ + process: "process/browser.js", + }), + new webpack.SourceMapDevToolPlugin({ + exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], + filename: "[file].map", + }), + ...requiredPlugins, + ]; + + /** + * @type {import("webpack").Configuration} + * This config compiles everything but the background + */ + const mainConfig = { + name: "main", + mode: ENV, + devtool: false, + entry: { + "popup/polyfills": "./src/popup/polyfills.ts", + "popup/main": params.popup.entry, + "content/trigger-autofill-script-injection": + "./src/autofill/content/trigger-autofill-script-injection.ts", + "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", + "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", + "content/bootstrap-autofill-overlay-menu": + "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", + "content/bootstrap-autofill-overlay-notifications": + "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", + "content/autofiller": "./src/autofill/content/autofiller.ts", + "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", + "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", + "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", + "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", + "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", + "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", + "notification/bar": "./src/autofill/notification/bar.ts", + "overlay/menu-button": + "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + "overlay/menu-list": + "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + "overlay/menu": + "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", + "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", + ...params.additionalEntries, + }, + cache: + ENV !== "development" + ? false + : { + type: "filesystem", + name: "main-cache", + cacheDirectory: path.resolve( + __dirname, + "../../node_modules/.cache/webpack-browser-main", + ), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, + optimization: { + minimize: ENV !== "development", + minimizer: [ + new TerserPlugin({ + exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], + terserOptions: { + // Replicate Angular CLI behaviour + compress: { + global_defs: { + ngDevMode: false, + ngI18nClosureMode: false, + }, + }, + }, + }), + ], + splitChunks: { + cacheGroups: { + commons: { + test(module, chunks) { + return ( + module.resource != null && + module.resource.includes(`${path.sep}node_modules${path.sep}`) && + !module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) + ); + }, + name: "popup/vendor", + chunks: (chunk) => { + return chunk.name === "popup/main"; + }, + }, + angular: { + test(module, chunks) { + return ( + module.resource != null && + module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) + ); + }, + name: "popup/vendor-angular", + chunks: (chunk) => { + return chunk.name === "popup/main"; + }, + }, + }, + }, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + fallback: { + assert: false, + buffer: require.resolve("buffer/"), + util: require.resolve("util/"), + url: require.resolve("url/"), + fs: false, + path: require.resolve("path-browserify"), + }, + cache: true, + }, + output: { + filename: "[name].js", + chunkFilename: "assets/[name].js", + webassemblyModuleFilename: "assets/[modulehash].wasm", + path: path.resolve(__dirname, "build"), + clean: true, + }, + module: { + rules: moduleRules, + }, + experiments: { + asyncWebAssembly: true, + }, + plugins: plugins, + }; + + /** + * @type {import("webpack").Configuration[]} + */ + const configs = []; + + if (manifestVersion == 2) { + mainConfig.optimization.splitChunks.cacheGroups.commons2 = { + test: /[\\/]node_modules[\\/]/, + name: "vendor", + chunks: (chunk) => { + return chunk.name === "background"; + }, + }; + + // Manifest V2 uses Background Pages which requires a html page. + mainConfig.plugins.push( + new HtmlWebpackPlugin({ + template: "./src/platform/background.html", + filename: "background.html", + chunks: ["vendor", "background"], + }), + ); + + // Manifest V2 background pages can be run through the regular build pipeline. + // Since it's a standard webpage. + mainConfig.entry.background = params.background.entry; + mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = + "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; + + configs.push(mainConfig); + } else { + // Firefox does not use the offscreen API + if (browser !== "firefox") { + mainConfig.entry["offscreen-document/offscreen-document"] = + "./src/platform/offscreen-document/offscreen-document.ts"; + + mainConfig.plugins.push( + new HtmlWebpackPlugin({ + template: "./src/platform/offscreen-document/index.html", + filename: "offscreen-document/index.html", + chunks: ["offscreen-document/offscreen-document"], + }), + ); + } + + const target = browser === "firefox" ? "web" : "webworker"; + + /** + * @type {import("webpack").Configuration} + */ + const backgroundConfig = { + name: "background", + mode: ENV, + devtool: false, + entry: params.background.entry, + target: target, + output: { + filename: "background.js", + path: path.resolve(__dirname, "build"), + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: "ts-loader", + }, + ], + }, + cache: + ENV !== "development" + ? false + : { + type: "filesystem", + name: "background-cache", + cacheDirectory: path.resolve( + __dirname, + "../../node_modules/.cache/webpack-browser-background", + ), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + plugins: [new TsconfigPathsPlugin()], + fallback: { + fs: false, + path: require.resolve("path-browserify"), + }, + cache: true, + }, + dependencies: ["main"], + plugins: [...requiredPlugins, new AngularCheckPlugin()], + }; + + // Safari's desktop build process requires a background.html and vendor.js file to exist + // within the root of the extension. This is a workaround to allow us to build Safari + // for manifest v2 and v3 without modifying the desktop project structure. + if (browser === "safari") { + backgroundConfig.plugins.push( + new CopyWebpackPlugin({ + patterns: [ + { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, + { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, + ], + }), + ); + } + + configs.push(mainConfig); + configs.push(backgroundConfig); + } + + return configs; +}; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index e62f90354d2..9eac990ab61 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -1,416 +1,13 @@ -const path = require("path"); -const webpack = require("webpack"); -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const TerserPlugin = require("terser-webpack-plugin"); -const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const manifest = require("./webpack/manifest"); -const AngularCheckPlugin = require("./webpack/angular-check"); +const { buildConfig } = require("./webpack.base"); -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = "development"; -} -const ENV = (process.env.ENV = process.env.NODE_ENV); -const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; -const browser = process.env.BROWSER ?? "chrome"; - -console.log(`Building Manifest Version ${manifestVersion} app`); - -const envConfig = configurator.load(ENV); -configurator.log(envConfig); - -const moduleRules = [ - { - test: /\.(html)$/, - loader: "html-loader", - }, - { - test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, - exclude: /loading.svg/, - generator: { - filename: "popup/fonts/[name].[contenthash][ext]", - }, - type: "asset/resource", - }, - { - test: /\.(jpe?g|png|gif|svg)$/i, - exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/, - generator: { - filename: "popup/images/[name][ext]", - }, - type: "asset/resource", - }, - { - test: /\.css$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "postcss-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.scss$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.[cm]?js$/, - use: [ - { - loader: "babel-loader", - options: { - configFile: "../../babel.config.json", - cacheDirectory: ENV === "development", - compact: ENV !== "development", - }, - }, - ], - }, - { - test: /\.[jt]sx?$/, - loader: "@ngtools/webpack", - }, -]; - -const requiredPlugins = [ - new webpack.DefinePlugin({ - "process.env": { - ENV: JSON.stringify(ENV), - }, - }), - new webpack.EnvironmentPlugin({ - FLAGS: envConfig.flags, - DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {}, - }), -]; - -const plugins = [ - new HtmlWebpackPlugin({ - template: "./src/popup/index.ejs", - filename: "popup/index.html", - chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], - browser: browser, - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/notification/bar.html", - filename: "notification/bar.html", - chunks: ["notification/bar"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/button/button.html", - filename: "overlay/menu-button.html", - chunks: ["overlay/menu-button"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/list/list.html", - filename: "overlay/menu-list.html", - chunks: ["overlay/menu-list"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", - filename: "overlay/menu.html", - chunks: ["overlay/menu"], - }), - new CopyWebpackPlugin({ - patterns: [ - { - from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", - to: "manifest.json", - transform: manifest.transform(browser), - }, - { from: "./src/managed_schema.json", to: "managed_schema.json" }, - { from: "./src/_locales", to: "_locales" }, - { from: "./src/images", to: "images" }, - { from: "./src/popup/images", to: "popup/images" }, - { from: "./src/autofill/content/autofill.css", to: "content" }, - ], - }), - new MiniCssExtractPlugin({ - filename: "[name].css", - chunkFilename: "chunk-[id].css", - }), - new AngularWebpackPlugin({ - tsConfigPath: "tsconfig.json", +module.exports = buildConfig({ + configName: "OSS", + popup: { + entry: "./src/popup/main.ts", entryModule: "src/popup/app.module#AppModule", - sourceMap: true, - }), - new webpack.ProvidePlugin({ - process: "process/browser.js", - }), - new webpack.SourceMapDevToolPlugin({ - exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], - filename: "[file].map", - }), - ...requiredPlugins, -]; - -/** - * @type {import("webpack").Configuration} - * This config compiles everything but the background - */ -const mainConfig = { - name: "main", - mode: ENV, - devtool: false, - entry: { - "popup/polyfills": "./src/popup/polyfills.ts", - "popup/main": "./src/popup/main.ts", - "content/trigger-autofill-script-injection": - "./src/autofill/content/trigger-autofill-script-injection.ts", - "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", - "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", - "content/bootstrap-autofill-overlay-menu": - "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", - "content/bootstrap-autofill-overlay-notifications": - "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", - "content/autofiller": "./src/autofill/content/autofiller.ts", - "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", - "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", - "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", - "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", - "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", - "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", - "notification/bar": "./src/autofill/notification/bar.ts", - "overlay/menu-button": - "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", - "overlay/menu-list": - "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", - "overlay/menu": - "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", - "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", - "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", }, - cache: - ENV !== "development" - ? false - : { - type: "filesystem", - name: "main-cache", - cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack-browser-main"), - buildDependencies: { - config: [__filename], - }, - }, - snapshot: { - unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], - }, - optimization: { - minimize: ENV !== "development", - minimizer: [ - new TerserPlugin({ - exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], - terserOptions: { - // Replicate Angular CLI behaviour - compress: { - global_defs: { - ngDevMode: false, - ngI18nClosureMode: false, - }, - }, - }, - }), - ], - splitChunks: { - cacheGroups: { - commons: { - test(module, chunks) { - return ( - module.resource != null && - module.resource.includes(`${path.sep}node_modules${path.sep}`) && - !module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) - ); - }, - name: "popup/vendor", - chunks: (chunk) => { - return chunk.name === "popup/main"; - }, - }, - angular: { - test(module, chunks) { - return ( - module.resource != null && - module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) - ); - }, - name: "popup/vendor-angular", - chunks: (chunk) => { - return chunk.name === "popup/main"; - }, - }, - }, - }, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - fallback: { - assert: false, - buffer: require.resolve("buffer/"), - util: require.resolve("util/"), - url: require.resolve("url/"), - fs: false, - path: require.resolve("path-browserify"), - }, - cache: true, - }, - output: { - filename: "[name].js", - chunkFilename: "assets/[name].js", - webassemblyModuleFilename: "assets/[modulehash].wasm", - path: path.resolve(__dirname, "build"), - clean: true, - }, - module: { - rules: moduleRules, - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: plugins, -}; - -/** - * @type {import("webpack").Configuration[]} - */ -const configs = []; - -if (manifestVersion == 2) { - mainConfig.optimization.splitChunks.cacheGroups.commons2 = { - test: /[\\/]node_modules[\\/]/, - name: "vendor", - chunks: (chunk) => { - return chunk.name === "background"; - }, - }; - - // Manifest V2 uses Background Pages which requires a html page. - mainConfig.plugins.push( - new HtmlWebpackPlugin({ - template: "./src/platform/background.html", - filename: "background.html", - chunks: ["vendor", "background"], - }), - ); - - // Manifest V2 background pages can be run through the regular build pipeline. - // Since it's a standard webpage. - mainConfig.entry.background = "./src/platform/background.ts"; - mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = - "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; - - configs.push(mainConfig); -} else { - // Firefox does not use the offscreen API - if (browser !== "firefox") { - mainConfig.entry["offscreen-document/offscreen-document"] = - "./src/platform/offscreen-document/offscreen-document.ts"; - - mainConfig.plugins.push( - new HtmlWebpackPlugin({ - template: "./src/platform/offscreen-document/index.html", - filename: "offscreen-document/index.html", - chunks: ["offscreen-document/offscreen-document"], - }), - ); - } - - const target = browser === "firefox" ? "web" : "webworker"; - - /** - * @type {import("webpack").Configuration} - */ - const backgroundConfig = { - name: "background", - mode: ENV, - devtool: false, + background: { entry: "./src/platform/background.ts", - target: target, - output: { - filename: "background.js", - path: path.resolve(__dirname, "build"), - }, - module: { - rules: [ - { - test: /\.tsx?$/, - loader: "ts-loader", - }, - ], - }, - cache: - ENV !== "development" - ? false - : { - type: "filesystem", - name: "background-cache", - cacheDirectory: path.resolve( - __dirname, - "../../node_modules/.cache/webpack-browser-background", - ), - buildDependencies: { - config: [__filename], - }, - }, - snapshot: { - unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], - }, - experiments: { - asyncWebAssembly: true, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - plugins: [new TsconfigPathsPlugin()], - fallback: { - fs: false, - path: require.resolve("path-browserify"), - }, - cache: true, - }, - dependencies: ["main"], - plugins: [...requiredPlugins, new AngularCheckPlugin()], - }; - - // Safari's desktop build process requires a background.html and vendor.js file to exist - // within the root of the extension. This is a workaround to allow us to build Safari - // for manifest v2 and v3 without modifying the desktop project structure. - if (browser === "safari") { - backgroundConfig.plugins.push( - new CopyWebpackPlugin({ - patterns: [ - { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, - { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, - ], - }), - ); - } - - configs.push(mainConfig); - configs.push(backgroundConfig); -} - -module.exports = configs; + }, + tsConfig: "tsconfig.json", +}); diff --git a/bitwarden_license/bit-browser/jest.config.js b/bitwarden_license/bit-browser/jest.config.js new file mode 100644 index 00000000000..5451c15e47c --- /dev/null +++ b/bitwarden_license/bit-browser/jest.config.js @@ -0,0 +1,22 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../tsconfig.base"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + setupFilesAfterEnv: ["../../apps/browser/test.setup.ts"], + moduleNameMapper: pathsToModuleNameMapper( + { + "@bitwarden/common/spec": ["libs/common/spec"], + "@bitwarden/common": ["libs/common/src/*"], + "@bitwarden/admin-console/common": ["libs/admin-console/src/common"], + ...(compilerOptions?.paths ?? {}), + }, + { + prefix: "/../../", + }, + ), +}; diff --git a/bitwarden_license/bit-browser/src/background/main.background.ts b/bitwarden_license/bit-browser/src/background/main.background.ts new file mode 100644 index 00000000000..48efc8099e6 --- /dev/null +++ b/bitwarden_license/bit-browser/src/background/main.background.ts @@ -0,0 +1,9 @@ +import OssMainBackground from "@bitwarden/browser/background/main.background"; + +export default class MainBackground { + private ossMain = new OssMainBackground(); + + async bootstrap() { + await this.ossMain.bootstrap(); + } +} diff --git a/bitwarden_license/bit-browser/src/platform/background.ts b/bitwarden_license/bit-browser/src/platform/background.ts new file mode 100644 index 00000000000..0cd9b3285e6 --- /dev/null +++ b/bitwarden_license/bit-browser/src/platform/background.ts @@ -0,0 +1,7 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + +import MainBackground from "../background/main.background"; + +const logService = new ConsoleLogService(false); +const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); +bitwardenMain.bootstrap().catch((error) => logService.error(error)); diff --git a/bitwarden_license/bit-browser/src/popup/app-routing.module.ts b/bitwarden_license/bit-browser/src/popup/app-routing.module.ts new file mode 100644 index 00000000000..7cabfb68e8d --- /dev/null +++ b/bitwarden_license/bit-browser/src/popup/app-routing.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +const routes: Routes = []; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AppRoutingModule {} diff --git a/bitwarden_license/bit-browser/src/popup/app.component.ts b/bitwarden_license/bit-browser/src/popup/app.component.ts new file mode 100644 index 00000000000..339681d66da --- /dev/null +++ b/bitwarden_license/bit-browser/src/popup/app.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from "@angular/core"; + +import { AppComponent as BaseAppComponent } from "@bitwarden/browser/popup/app.component"; + +@Component({ + selector: "app-root", + templateUrl: "../../../../apps/browser/src/popup/app.component.html", + standalone: false, +}) +export class AppComponent extends BaseAppComponent implements OnInit { + ngOnInit() { + return super.ngOnInit(); + } +} diff --git a/bitwarden_license/bit-browser/src/popup/app.module.ts b/bitwarden_license/bit-browser/src/popup/app.module.ts new file mode 100644 index 00000000000..589d7794724 --- /dev/null +++ b/bitwarden_license/bit-browser/src/popup/app.module.ts @@ -0,0 +1,39 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +// import { AppRoutingAnimationsModule } from "@bitwarden/browser/popup/app-routing-animations"; +import { AppRoutingModule as OssRoutingModule } from "@bitwarden/browser/popup/app-routing.module"; +import { AppModule as OssModule } from "@bitwarden/browser/popup/app.module"; +// import { WildcardRoutingModule } from "@bitwarden/browser/popup/wildcard-routing.module"; + +import { AppRoutingModule } from "./app-routing.module"; +import { AppComponent } from "./app.component"; +/** + * This is the AppModule for the commercial version of Bitwarden. + * `apps/browser/app.module.ts` contains the OSS version. + * + * You probably do not want to modify this file. Consider editing `oss.module.ts` instead. + */ +@NgModule({ + imports: [ + CommonModule, + OverlayModule, + OssModule, + JslibModule, + // BrowserAnimationsModule, + // FormsModule, + // ReactiveFormsModule, + // CoreModule, + // DragDropModule, + AppRoutingModule, + OssRoutingModule, + RouterModule, + // WildcardRoutingModule, // Needs to be last to catch all non-existing routes + ], + declarations: [AppComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/bitwarden_license/bit-browser/src/popup/main.ts b/bitwarden_license/bit-browser/src/popup/main.ts new file mode 100644 index 00000000000..2c8c68e1a26 --- /dev/null +++ b/bitwarden_license/bit-browser/src/popup/main.ts @@ -0,0 +1,26 @@ +import { enableProdMode } from "@angular/core"; +import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; + +import { PopupSizeService } from "@bitwarden/browser/platform/popup/layout/popup-size.service"; +import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service"; + +import { AppModule } from "./app.module"; + +import "@bitwarden/browser/popup/scss"; + +// We put these first to minimize the delay in window changing. +PopupSizeService.initBodyWidthFromLocalStorage(); +// Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861] +if (BrowserPlatformUtilsService.shouldApplySafariHeightFix(window)) { + document.documentElement.classList.add("safari_height_fix"); +} + +if (process.env.ENV === "production") { + enableProdMode(); +} + +function init() { + void platformBrowserDynamic().bootstrapModule(AppModule); +} + +init(); diff --git a/bitwarden_license/bit-browser/tsconfig.json b/bitwarden_license/bit-browser/tsconfig.json new file mode 100644 index 00000000000..c3420e280b7 --- /dev/null +++ b/bitwarden_license/bit-browser/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../apps/browser/tsconfig", + "include": [ + "src", + + "../../apps/browser/src/**/*.d.ts", + + "../../apps/browser/src/autofill/content/*.ts", + "../../apps/browser/src/autofill/fido2/content/*.ts", + "../../apps/browser/src/autofill/notification/bar.ts", + "../../apps/browser/src/autofill/overlay/inline-menu/**/*.ts", + "../../apps/browser/src/platform/ipc/content/*.ts", + "../../apps/browser/src/platform/offscreen-document/offscreen-document.ts", + "../../apps/browser/src/popup/polyfills.ts", + "../../apps/browser/src/vault/content/*.ts", + + "../../libs/common/src/autofill/constants", + "../../libs/common/custom-matchers.d.ts" + ] +} diff --git a/bitwarden_license/bit-browser/tsconfig.spec.json b/bitwarden_license/bit-browser/tsconfig.spec.json new file mode 100644 index 00000000000..968309bca6a --- /dev/null +++ b/bitwarden_license/bit-browser/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": true, + "emitDecoratorMetadata": false + }, + "files": ["../../apps/browser/test.setup.ts"] +} diff --git a/bitwarden_license/bit-browser/webpack.config.js b/bitwarden_license/bit-browser/webpack.config.js new file mode 100644 index 00000000000..294e2f16253 --- /dev/null +++ b/bitwarden_license/bit-browser/webpack.config.js @@ -0,0 +1,13 @@ +const { buildConfig } = require("../../apps/browser/webpack.base"); + +module.exports = buildConfig({ + configName: "Commercial", + popup: { + entry: "../../bitwarden_license/bit-browser/src/popup/main.ts", + entryModule: "../../bitwarden_license/bit-browser/src/popup/app.module#AppModule", + }, + background: { + entry: "../../bitwarden_license/bit-browser/src/platform/background.ts", + }, + tsConfig: "../../bitwarden_license/bit-browser/tsconfig.json", +}); diff --git a/jest.config.js b/jest.config.js index 3e4fb665a8c..9be54e99995 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,9 +20,10 @@ module.exports = { "/apps/cli/jest.config.js", "/apps/desktop/jest.config.js", "/apps/web/jest.config.js", - "/bitwarden_license/bit-web/jest.config.js", + "/bitwarden_license/bit-browser/jest.config.js", "/bitwarden_license/bit-cli/jest.config.js", "/bitwarden_license/bit-common/jest.config.js", + "/bitwarden_license/bit-web/jest.config.js", "/libs/admin-console/jest.config.js", "/libs/angular/jest.config.js", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3d38f0f8210..3d1d2915f67 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,6 +26,7 @@ "@bitwarden/auth/common": ["./libs/auth/src/common"], "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], + "@bitwarden/browser/*": ["./apps/browser/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], "@bitwarden/client-type": ["libs/client-type/src/index.ts"], "@bitwarden/common/*": ["./libs/common/src/*"], From 66f5700a75ffc114dac0abf1fcb9f9ab64e9d525 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:21:57 +0200 Subject: [PATCH 18/25] [PM-24748][PM-24072] Chromium importer (#16100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add importer dummy lib, add cargo deps for win/mac * Add Chromium importer source from bitwarden/password-access * Mod crypto is no more * Expose some Chromium importer functions via NAPI, replace home with home_dir crate * Add Chromium importer to the main <-> renderer IPC, export all functions from Rust * Add password and notes fields to the imported logins * Fix windows to use homedir instead of home * Return success/failure results * Import from account logins and join * Linux v10 support * Use mod util on Windows * Use mod util on macOS * Refactor to move shared code into chromium.rs * Fix windows * Fix Linux as well * Linux v11 support for Chrome/Gnome, everything is async now * Support multiple browsers on Linux v11 * Move oo7 to Linux * Fix Windows * Fix macOS * Add support for Brave browser in Linux configuration * Add support for Opera browser in Linux configuration * Fix Edge and add Arc on macOS * Add Opera on macOS * Add support for Vivaldi browser in macOS configuration * Add support for Chromium browser in macOS configuration * Fix Edge on Windows * Add Opera on Windows * Add Vivaldi on windows * Add Chromium to supported browsers on Windows * stub out UI options for chromium direct import * call IPC funcs from import-desktop * add notes to chrome csv importer * remove (csv) from import tool names and format item names as hostnames * Add ABE/v20 encryption support * ABE/v20 architecture description * Add a build step to produce admin.exe and service.exe * Add Windows v20/ABE configuration functionality to specify the full path to the admin.exe and service.exe. Use ipc.platform.chromiumImporter.configureWindowsCryptoService to configure the Chromium importer on Windows. * rename ARCHITECTURE.md to README.md * aligns with guidance from architecture re: in-repository documentation. * also fixes a failing lint. * cargo fmt * cargo clippy fix * Declare feature flag for using chromium importer * Linter fix after executing npm run prettier * Use feature flag to guard the use of the chromium importer * Added temporary logging to further debug, why the Angular change detection isn't working as expected * introduce importer metadata; host metadata from service; includes tests * fix cli build * Register autotype module in lib.rs introduce by a bad merge * Fix web build * Fix issue with loaders being undefined and the feature flag turned off * Add missing Chromium support when selecting chromecsv * debugging * remove chromium support from chromecsv metadata * fix default loader selection * [PM-24753] cargo lib file (#16090) * Add new modules * Fix chromium importer * Fix compile bugs for toolchain * remove importer folder * remove IPC code * undo setting change * clippy fixes * cargo fmt * clippy fixes * clippy fixes * clippy fixes * clippy fixes * lint fix * fix release build * Add files in CODEOWNERS * Create tools owned preload.ts * Move chromium-importer.service under tools-ownership * Fix typeError When accessing the Chromium direct import options the file button is hidden, so trying to access it's values will fail * Fix tools owned preload * Remove dead code and redundant truncation * Remove configureWindowsCryptoService function/methods * Clean up cargo files * Fix unused async * Update apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml Co-authored-by: Oscar Hinton * Fix napi deps * fix lints * format * fix linux lint * fix windows lints * format * fix missing `?` * fix a different missing `?` --------- Co-authored-by: Dmitry Yakimenko Co-authored-by: Kyle Spearrin Co-authored-by: Daniel James Smith Co-authored-by: ✨ Audrey ✨ Co-authored-by: ✨ Audrey ✨ Co-authored-by: adudek-bw Co-authored-by: Oscar Hinton --- .github/CODEOWNERS | 1 + .../browser/src/background/main.background.ts | 13 +- .../service-container/service-container.ts | 13 +- apps/desktop/desktop_native/Cargo.lock | 106 +++++- apps/desktop/desktop_native/Cargo.toml | 2 +- .../bitwarden_chromium_importer/Cargo.toml | 38 ++ .../bitwarden_chromium_importer/README.md | 156 ++++++++ .../src/chromium.rs | 350 ++++++++++++++++++ .../bitwarden_chromium_importer/src/crypto.rs | 17 + .../bitwarden_chromium_importer/src/lib.rs | 1 + .../bitwarden_chromium_importer/src/linux.rs | 153 ++++++++ .../bitwarden_chromium_importer/src/macos.rs | 164 ++++++++ .../bitwarden_chromium_importer/src/util.rs | 43 +++ .../src/windows.rs | 205 ++++++++++ apps/desktop/desktop_native/build.js | 2 +- apps/desktop/desktop_native/napi/Cargo.toml | 1 + apps/desktop/desktop_native/napi/index.d.ts | 24 ++ apps/desktop/desktop_native/napi/src/lib.rs | 90 +++++ .../tools/import/chromium-importer.service.ts | 22 ++ .../import/import-desktop.component.html | 2 + .../tools/import/import-desktop.component.ts | 8 + apps/desktop/src/app/tools/preload.ts | 14 + apps/desktop/src/locales/en/messages.json | 6 + apps/desktop/src/main.ts | 3 + apps/desktop/src/preload.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 2 + libs/common/src/tools/providers.spec.ts | 178 +++++++++ libs/common/src/tools/providers.ts | 45 ++- .../chrome/import-chrome.component.html | 8 + .../chrome/import-chrome.component.ts | 167 +++++++++ libs/importer/src/components/chrome/index.ts | 1 + .../src/components/import.component.html | 70 ++-- .../src/components/import.component.ts | 105 +++--- .../src/components/importer-providers.ts | 91 +++++ .../src/importers/chrome-csv-importer.ts | 1 + libs/importer/src/metadata/availability.ts | 15 + libs/importer/src/metadata/data.ts | 27 ++ libs/importer/src/metadata/importers.ts | 27 ++ libs/importer/src/metadata/index.ts | 4 + libs/importer/src/metadata/types.ts | 20 + libs/importer/src/models/import-options.ts | 9 +- .../services/import.service.abstraction.ts | 7 + .../src/services/import.service.spec.ts | 191 +++++++++- libs/importer/src/services/import.service.ts | 43 ++- libs/importer/src/util.spec.ts | 60 +++ libs/importer/src/util.ts | 19 + 46 files changed, 2436 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/README.md create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs create mode 100644 apps/desktop/src/app/tools/import/chromium-importer.service.ts create mode 100644 apps/desktop/src/app/tools/preload.ts create mode 100644 libs/common/src/tools/providers.spec.ts create mode 100644 libs/importer/src/components/chrome/import-chrome.component.html create mode 100644 libs/importer/src/components/chrome/import-chrome.component.ts create mode 100644 libs/importer/src/components/chrome/index.ts create mode 100644 libs/importer/src/components/importer-providers.ts create mode 100644 libs/importer/src/metadata/availability.ts create mode 100644 libs/importer/src/metadata/data.ts create mode 100644 libs/importer/src/metadata/importers.ts create mode 100644 libs/importer/src/metadata/index.ts create mode 100644 libs/importer/src/metadata/types.ts create mode 100644 libs/importer/src/util.spec.ts create mode 100644 libs/importer/src/util.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6028d106db..c190a77068d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,6 +29,7 @@ libs/common/src/auth @bitwarden/team-auth-dev apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev apps/desktop/src/app/tools @bitwarden/team-tools-dev +apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a14d43fd218..4d227330184 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -158,10 +158,13 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; @@ -1056,8 +1059,16 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualVaultExportService = new IndividualVaultExportService( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e3359c17b91..76eeb340550 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -113,10 +113,13 @@ import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; @@ -816,8 +819,16 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualExportService = new IndividualVaultExportService( diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 70814c74106..bb7f7d9995b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -447,6 +447,32 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "bitwarden_chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "hex", + "homedir", + "log", + "oo7", + "pbkdf2", + "rand 0.9.1", + "rusqlite", + "security-framework", + "serde", + "serde_json", + "sha1", + "tokio", + "winapi", + "windows 0.61.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -586,9 +612,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -596,9 +622,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -608,9 +634,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ "heck", "proc-macro2", @@ -922,6 +948,7 @@ dependencies = [ "anyhow", "autotype", "base64", + "bitwarden_chromium_importer", "desktop_core", "hex", "log", @@ -1165,6 +1192,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1424,6 +1463,18 @@ name = "hashbrown" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.3", +] [[package]] name = "heck" @@ -1689,6 +1740,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-cplusplus" version = "1.0.10" @@ -2642,6 +2704,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "russh-cryptovec" version = "0.7.3" @@ -2846,6 +2922,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -3178,6 +3265,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3496,6 +3584,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index b5819d3688e..84b835de35f 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "autotype", + "bitwarden_chromium_importer", "core", "macos_provider", "napi", @@ -21,7 +22,6 @@ anyhow = "=1.0.94" arboard = { version = "=3.6.0", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" -bindgen = "=0.72.0" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" bytes = "=1.10.1" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml new file mode 100644 index 00000000000..8512ed1b319 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "bitwarden_chromium_importer" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[dependencies] +aes = { workspace = true } +aes-gcm = "=0.10.3" +anyhow = { workspace = true } +async-trait = "=0.1.88" +base64 = { workspace = true } +cbc = { workspace = true, features = ["alloc"] } +hex = { workspace = true } +homedir = { workspace = true } +log = { workspace = true } +pbkdf2 = "=0.12.2" +rand = { workspace = true } +rusqlite = { version = "=0.35.0", features = ["bundled"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = "=0.10.6" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true, features = ["full"] } +winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] } +windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +oo7 = { workspace = true } + +[lints] +workspace = true + diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md new file mode 100644 index 00000000000..498dd3ac67d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md @@ -0,0 +1,156 @@ +# Windows ABE Architecture + +## Overview + +The Windows Application Bound Encryption (ABE) consists of three main components that work together: + +- **client library** -- Library that is part of the desktop client application +- **admin.exe** -- Service launcher running as ADMINISTRATOR +- **service.exe** -- Background Windows service running as SYSTEM + +_(The names of the binaries will be changed for the released product.)_ + +## The goal + +The goal of this subsystem is to decrypt the master encryption key with which the login information +is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and +Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles. + +The general idea of this encryption scheme is that Chrome generates a unique random encryption key, +then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection +API at the user level, and then, using an installed service, encrypts it with the Windows Data +Protection API at the system level on top of that. This triply encrypted key is later stored in the +`Local State` file. + +The next paragraphs describe what is done at each level to decrypt the key. + +## 1. Client library + +This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows +(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges +by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation +in `windows.rs`. + +This function takes three arguments: + +1. Absolute path to `admin.exe` +2. Absolute path to `service.exe` +3. Base64 string of the ABE key extracted from the browser's local state + +It's not possible to install the service from the user-level executable. So first, we have to +elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute` +with the `runas` verb. Since it's not trivial to read the standard output from an application +launched in this way, a named pipe server is created at the user level, which waits for the response +from `admin.exe` after it has been launched. + +The name of the service executable and the data to be decrypted are passed via the command line to +`admin.exe` like this: + +```bat +admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +``` + +**At this point, the user must permit the action to be performed on the UAC screen.** + +## 2. Admin executable + +This executable receives the full path of `service.exe` and the data to be decrypted. + +First, it installs the service to run as SYSTEM and waits for it to start running. The service +creates a named pipe server that the admin-level executable communicates with (see the `service.exe` +description further down). + +It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer +could be a success or a failure. In case of success, it's a base64 string decrypted at the system +level. In case of failure, it's an error message prefixed with an `!`. In either case, the response +is sent to the named pipe server created by the user. The user responds with `ok` (ignored). + +After that, the executable stops and uninstalls the service and then exits. + +## 3. System service + +The service starts and creates a named pipe server for communication between `admin.exe` and the +system service. Please note that it is not possible to communicate between the user and the system +service directly via a named pipe. Thus, this three-layered approach is necessary. + +Once the service is started, it waits for the incoming message via the named pipe. The expected +message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection +API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In +case of an error, the error message is sent back prefixed with an `!`. + +The service keeps running and servicing more requests if there are any, until it's stopped and +removed from the system. Even though we send only one request, the service is designed to handle as +many clients with as many messages as needed and could be installed on the system permanently if +necessary. + +## 4. Back to client library + +The decrypted base64-encoded string comes back from the admin executable to the named pipe server at +the user level. At this point, it has been decrypted only once at the system level. + +In the next step, the string is decrypted at the user level with the same Windows Data Protection +API. + +And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe` +from the Chrome installation. Based on the version of the encrypted string (encoded in the string +itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in +`windows.rs`. + +After all of these steps, we have the master key which can be used to decrypt the password +information stored in the local database. + +## Summary + +The Windows ABE decryption process involves a three-tier architecture with named pipe communication: + +```mermaid +sequenceDiagram + participant Client as Client Library (User) + participant Admin as admin.exe (Administrator) + participant Service as service.exe (System) + + Client->>Client: Create named pipe server + Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user + + Client->>Admin: Launch with UAC elevation + Note over Client,Admin: --service-exe c:\path\to\service.exe + Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... + + Client->>Client: Wait for response + + Admin->>Service: Install & start service + Note over Admin,Service: c:\path\to\service.exe + + Service->>Service: Create named pipe server + Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin + + Service->>Service: Wait for message + + Admin->>Service: Send encrypted data via admin-service pipe + Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE... + + Admin->>Admin: Wait for response + + Service->>Service: Decrypt with system-level DPAPI + + Service->>Admin: Return decrypted data via admin-service pipe + Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Admin->>Client: Send result via named user-admin pipe + Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Client->>Admin: Send ACK to admin + Note over Client,Admin: ok + + Admin->>Service: Stop & uninstall service + Service-->>Admin: Exit + + Admin-->>Client: Exit + + Client->>Client: Decrypt with user-level DPAPI + + Client->>Client: Decrypt with hardcoded key + Note over Client: AES-256-GCM or ChaCha20Poly1305 + + Client->>Client: Done +``` diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs new file mode 100644 index 00000000000..8179a10213d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs @@ -0,0 +1,350 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use hex::decode; +use homedir::my_home; +use rusqlite::{params, Connection}; + +// Platform-specific code +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod platform; + +// +// Public API +// + +#[derive(Debug)] +pub struct ProfileInfo { + pub name: String, + pub folder: String, + + #[allow(dead_code)] + pub account_name: Option, + + #[allow(dead_code)] + pub account_email: Option, +} + +#[derive(Debug)] +pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, +} + +#[derive(Debug)] +pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, +} + +#[derive(Debug)] +pub enum LoginImportResult { + Success(Login), + Failure(LoginImportFailure), +} + +// TODO: Make thus async +pub fn get_installed_browsers() -> Result> { + let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); + + for (browser, config) in SUPPORTED_BROWSER_MAP.iter() { + let data_dir = get_browser_data_dir(config)?; + if data_dir.exists() { + browsers.push((*browser).to_string()); + } + } + + Ok(browsers) +} + +// TODO: Make thus async +pub fn get_available_profiles(browser_name: &String) -> Result> { + let (_, local_state) = load_local_state_for_browser(browser_name)?; + Ok(get_profile_info(&local_state)) +} + +pub async fn import_logins( + browser_name: &String, + profile_id: &String, +) -> Result> { + let (data_dir, local_state) = load_local_state_for_browser(browser_name)?; + + let mut crypto_service = platform::get_crypto_service(browser_name, &local_state) + .map_err(|e| anyhow!("Failed to get crypto service: {}", e))?; + + let local_logins = get_logins(&data_dir, profile_id, "Login Data") + .map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // This is not available in all browsers, but there's no harm in trying. If the file doesn't exist we just get an empty vector. + let account_logins = get_logins(&data_dir, profile_id, "Login Data For Account") + .map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // TODO: Do we need a better merge strategy? Maybe ignore duplicates at least? + // TODO: Should we also ignore an error from one of the two imports? If one is successful and the other fails, + // should we still return the successful ones? At the moment it doesn't fail for a missing file, only when + // something goes really wrong. + let all_logins = local_logins + .into_iter() + .chain(account_logins.into_iter()) + .collect::>(); + + let results = decrypt_logins(all_logins, &mut crypto_service).await; + + Ok(results) +} + +// +// Private +// + +#[derive(Debug)] +struct BrowserConfig { + name: &'static str, + data_dir: &'static str, +} + +static SUPPORTED_BROWSER_MAP: LazyLock< + std::collections::HashMap<&'static str, &'static BrowserConfig>, +> = LazyLock::new(|| { + platform::SUPPORTED_BROWSERS + .iter() + .map(|b| (b.name, b)) + .collect::>() +}); + +fn get_browser_data_dir(config: &BrowserConfig) -> Result { + let dir = my_home() + .map_err(|_| anyhow!("Home directory not found"))? + .ok_or_else(|| anyhow!("Home directory not found"))? + .join(config.data_dir); + Ok(dir) +} + +// +// CryptoService +// + +#[async_trait] +trait CryptoService: Send { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; +} + +#[derive(serde::Deserialize, Clone)] +struct LocalState { + profile: AllProfiles, + #[allow(dead_code)] + os_crypt: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct AllProfiles { + info_cache: std::collections::HashMap, +} + +#[derive(serde::Deserialize, Clone)] +struct OneProfile { + name: String, + gaia_name: Option, + user_name: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct OsCrypt { + #[allow(dead_code)] + encrypted_key: Option, + #[allow(dead_code)] + app_bound_encrypted_key: Option, +} + +fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, LocalState)> { + let config = SUPPORTED_BROWSER_MAP + .get(browser_name.as_str()) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + let data_dir = get_browser_data_dir(config)?; + if !data_dir.exists() { + return Err(anyhow!( + "Browser user data directory '{}' not found", + data_dir.display() + )); + } + + let local_state = load_local_state(&data_dir)?; + + Ok((data_dir, local_state)) +} + +fn load_local_state(browser_dir: &Path) -> Result { + let local_state = std::fs::read_to_string(browser_dir.join("Local State")) + .map_err(|e| anyhow!("Failed to read local state file: {}", e))?; + + serde_json::from_str(&local_state) + .map_err(|e| anyhow!("Failed to parse local state JSON: {}", e)) +} + +fn get_profile_info(local_state: &LocalState) -> Vec { + let mut profile_infos = Vec::new(); + for (name, info) in local_state.profile.info_cache.iter() { + profile_infos.push(ProfileInfo { + name: info.name.clone(), + folder: name.clone(), + account_name: info.gaia_name.clone(), + account_email: info.user_name.clone(), + }); + } + profile_infos +} + +struct EncryptedLogin { + url: String, + username: String, + encrypted_password: Vec, + encrypted_note: Vec, +} + +fn get_logins( + browser_dir: &Path, + profile_id: &String, + filename: &str, +) -> Result> { + let login_data_path = browser_dir.join(profile_id).join(filename); + + // Sometimes database files are not present, so nothing to import + if !login_data_path.exists() { + return Ok(vec![]); + } + + // When the browser with the current profile is open the database file is locked. + // To access it we need to copy it to a temporary location. + let tmp_db_path = std::env::temp_dir().join(format!( + "tmp-logins-{}-{}.db", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| anyhow!("Failed to retrieve system time: {}", e))? + .as_millis(), + rand::random::() + )); + + std::fs::copy(&login_data_path, &tmp_db_path).map_err(|e| { + anyhow!( + "Failed to copy the password database file at {:?}: {}", + login_data_path, + e + ) + })?; + + let tmp_db_path = tmp_db_path + .to_str() + .ok_or_else(|| anyhow!("Failed to locate database."))?; + let maybe_logins = + query_logins(tmp_db_path).map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // Clean up temp file + let _ = std::fs::remove_file(tmp_db_path); + + Ok(maybe_logins) +} + +fn hex_to_bytes(hex: &str) -> Vec { + decode(hex).unwrap_or_default() +} + +fn does_table_exist(conn: &Connection, table_name: &str) -> Result { + let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?; + let exists = stmt.exists(params![table_name])?; + Ok(exists) +} + +fn query_logins(db_path: &str) -> Result, rusqlite::Error> { + let conn = Connection::open(db_path)?; + + let have_logins = does_table_exist(&conn, "logins")?; + let have_password_notes = does_table_exist(&conn, "password_notes")?; + if !have_logins || !have_password_notes { + return Ok(vec![]); + } + + let mut stmt = conn.prepare( + r#" + SELECT + l.origin_url AS url, + l.username_value AS username, + hex(l.password_value) AS encryptedPasswordHex, + hex(pn.value) AS encryptedNoteHex + FROM + logins l + LEFT JOIN + password_notes pn ON l.id = pn.parent_id + WHERE + l.blacklisted_by_user = 0 + "#, + )?; + + let logins_iter = stmt.query_map((), |row| { + let url: String = row.get("url")?; + let username: String = row.get("username")?; + let encrypted_password_hex: String = row.get("encryptedPasswordHex")?; + let encrypted_note_hex: String = row.get("encryptedNoteHex")?; + Ok(EncryptedLogin { + url, + username, + encrypted_password: hex_to_bytes(&encrypted_password_hex), + encrypted_note: hex_to_bytes(&encrypted_note_hex), + }) + })?; + + let mut logins = Vec::new(); + for login in logins_iter { + logins.push(login?); + } + + Ok(logins) +} + +async fn decrypt_logins( + encrypted_logins: Vec, + crypto_service: &mut Box, +) -> Vec { + let mut results = Vec::with_capacity(encrypted_logins.len()); + for encrypted_login in encrypted_logins { + let result = decrypt_login(encrypted_login, crypto_service).await; + results.push(result); + } + results +} + +async fn decrypt_login( + encrypted_login: EncryptedLogin, + crypto_service: &mut Box, +) -> LoginImportResult { + let maybe_password = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_password) + .await; + match maybe_password { + Ok(password) => { + let note = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_note) + .await + .unwrap_or_default(); + + LoginImportResult::Success(Login { + url: encrypted_login.url, + username: encrypted_login.username, + password, + note, + }) + } + Err(e) => LoginImportResult::Failure(LoginImportFailure { + url: encrypted_login.url, + username: encrypted_login.username, + error: e.to_string(), + }), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs new file mode 100644 index 00000000000..a2b87d758a4 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs @@ -0,0 +1,17 @@ +//! Cryptographic primitives used in the SDK + +use anyhow::{Result, anyhow}; + +use aes::cipher::{ + block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit, +}; + +pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { + let iv = GenericArray::from_slice(iv); + let mut data = data.to_vec(); + return cbc::Decryptor::::new(&key, iv) + .decrypt_padded_mut::(&mut data) + .map_err(|_| anyhow!("Failed to decrypt data"))?; + + Ok(data) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs new file mode 100644 index 00000000000..b0a399d6321 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs @@ -0,0 +1 @@ +pub mod chromium; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs new file mode 100644 index 00000000000..0ead034a4b2 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use oo7::XDG_SCHEMA_ATTRIBUTE; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod util; + +// +// Public API +// + +// TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.). +pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ + BrowserConfig { + name: "Chrome", + data_dir: ".config/google-chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "snap/chromium/common/chromium", + }, + BrowserConfig { + name: "Brave", + data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Opera", + data_dir: "snap/opera/current/.config/opera", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYRING_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + let service = LinuxCryptoService::new(config); + Ok(Box::new(service)) +} + +// +// Private +// + +#[derive(Debug)] +struct KeyringConfig { + browser: &'static str, + application_id: &'static str, +} + +const KEYRING_CONFIG: [KeyringConfig; SUPPORTED_BROWSERS.len()] = [ + KeyringConfig { + browser: "Chrome", + application_id: "chrome", + }, + KeyringConfig { + browser: "Chromium", + application_id: "chromium", + }, + KeyringConfig { + browser: "Brave", + application_id: "brave", + }, + KeyringConfig { + browser: "Opera", + application_id: "opera", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; +const V10_KEY: [u8; 16] = [ + 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, +]; + +struct LinuxCryptoService { + config: &'static KeyringConfig, + v11_key: Option>, +} + +impl LinuxCryptoService { + fn new(config: &'static KeyringConfig) -> Self { + Self { + config, + v11_key: None, + } + } + + fn decrypt_v10(&self, encrypted: &[u8]) -> Result { + decrypt(&V10_KEY, encrypted) + } + + async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result { + if self.v11_key.is_none() { + let master_password = get_master_password(self.config.application_id).await?; + self.v11_key = Some(util::derive_saltysalt(&master_password, 1)?); + } + + let key = self + .v11_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + decrypt(key, encrypted) + } +} + +#[async_trait] +impl CryptoService for LinuxCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + let (version, password) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v11"])?; + + let result = match version { + "v10" => self.decrypt_v10(password), + "v11" => self.decrypt_v11(password).await, + _ => Err(anyhow!("Logic error: unreachable code")), + }?; + + Ok(result) + } +} + +fn decrypt(key: &[u8], encrypted: &[u8]) -> Result { + let plaintext = util::decrypt_aes_128_cbc(key, &IV, encrypted)?; + String::from_utf8(plaintext).map_err(|e| anyhow!("UTF-8 error: {:?}", e)) +} + +async fn get_master_password(application_tag: &str) -> Result> { + let keyring = oo7::Keyring::new().await?; + keyring.unlock().await?; + + let attributes = HashMap::from([ + ( + XDG_SCHEMA_ATTRIBUTE, + "chrome_libsecret_os_crypt_password_v2", + ), + ("application", application_tag), + ]); + + let results = keyring.search_items(&attributes).await?; + match results.first() { + Some(r) => { + let secret = r.secret().await?; + Ok(secret.to_vec()) + } + None => Err(anyhow!("The master password not found in the keyring")), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs new file mode 100644 index 00000000000..d9aeff68f2b --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs @@ -0,0 +1,164 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use security_framework::passwords::get_generic_password; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ + BrowserConfig { + name: "Chrome", + data_dir: "Library/Application Support/Google/Chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "Library/Application Support/Chromium", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "Library/Application Support/Microsoft Edge", + }, + BrowserConfig { + name: "Brave", + data_dir: "Library/Application Support/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Arc", + data_dir: "Library/Application Support/Arc/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "Library/Application Support/com.operasoftware.Opera", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "Library/Application Support/Vivaldi", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYCHAIN_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + Ok(Box::new(MacCryptoService::new(config))) +} + +// +// Private +// + +#[derive(Debug)] +struct KeychainConfig { + browser: &'static str, + service: &'static str, + account: &'static str, +} + +const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [ + KeychainConfig { + browser: "Chrome", + service: "Chrome Safe Storage", + account: "Chrome", + }, + KeychainConfig { + browser: "Chromium", + service: "Chromium Safe Storage", + account: "Chromium", + }, + KeychainConfig { + browser: "Microsoft Edge", + service: "Microsoft Edge Safe Storage", + account: "Microsoft Edge", + }, + KeychainConfig { + browser: "Brave", + service: "Brave Safe Storage", + account: "Brave", + }, + KeychainConfig { + browser: "Arc", + service: "Arc Safe Storage", + account: "Arc", + }, + KeychainConfig { + browser: "Opera", + service: "Opera Safe Storage", + account: "Opera", + }, + KeychainConfig { + browser: "Vivaldi", + service: "Vivaldi Safe Storage", + account: "Vivaldi", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character) + +// +// CryptoService +// + +struct MacCryptoService { + config: &'static KeychainConfig, + master_key: Option>, +} + +impl MacCryptoService { + fn new(config: &'static KeychainConfig) -> Self { + Self { + config, + master_key: None, + } + } +} + +#[async_trait] +impl CryptoService for MacCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On macOS only v10 is supported + let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?; + + // This might bring up the admin password prompt + if self.master_key.is_none() { + self.master_key = Some(get_master_key(self.config.service, self.config.account)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + let plaintext = + String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +fn get_master_key(service: &str, account: &str) -> Result> { + let master_password = get_master_password(service, account)?; + let key = util::derive_saltysalt(&master_password, 1003)?; + Ok(key) +} + +fn get_master_password(service: &str, account: &str) -> Result> { + let password = get_generic_password(service, account) + .map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?; + + Ok(password) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs new file mode 100644 index 00000000000..5edd4a2610f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs @@ -0,0 +1,43 @@ +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; +use anyhow::{anyhow, Result}; +use pbkdf2::{hmac::Hmac, pbkdf2}; +use sha1::Sha1; + +pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { + if encrypted.len() < 3 { + return Err(anyhow!( + "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", + encrypted.len() + )); + } + + let (version, password) = encrypted.split_at(3); + Ok((std::str::from_utf8(version)?, password)) +} + +pub fn split_encrypted_string_and_validate<'a>( + encrypted: &'a [u8], + supported_versions: &[&str], +) -> Result<(&'a str, &'a [u8])> { + let (version, password) = split_encrypted_string(encrypted)?; + if !supported_versions.contains(&version) { + return Err(anyhow!("Unsupported encryption version: {}", version)); + } + + Ok((version, password)) +} + +pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { + let decryptor = cbc::Decryptor::::new_from_slices(key, iv)?; + let plaintext = decryptor + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + Ok(plaintext) +} + +pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + let mut key = vec![0u8; 16]; + pbkdf2::>(password, b"saltysalt", iterations, &mut key) + .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; + Ok(key) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs new file mode 100644 index 00000000000..e7dffe93dba --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs @@ -0,0 +1,205 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use winapi::shared::minwindef::{BOOL, BYTE, DWORD}; +use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB}; +use windows::Win32::Foundation::{LocalFree, HLOCAL}; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +#[allow(dead_code)] +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, + BrowserConfig { + name: "Chromium", + data_dir: "AppData/Local/Chromium/User Data", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "AppData/Local/Microsoft/Edge/User Data", + }, + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + let (version, no_prefix) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; + + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = unprotect_data_win(&key_bytes[5..]) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } +} + +fn unprotect_data_win(data: &[u8]) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let mut data_in = DATA_BLOB { + cbData: data.len() as DWORD, + pbData: data.as_ptr() as *mut BYTE, + }; + + let mut data_out = DATA_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + + let result: BOOL = unsafe { + // BOOL from winapi (i32) + CryptUnprotectData( + &mut data_in, + std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) + std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB + std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) + std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT + 0, // dwFlags: DWORD + &mut data_out, + ) + }; + + if result == 0 { + return Err(anyhow!("CryptUnprotectData failed")); + } + + if data_out.pbData.is_null() || data_out.cbData == 0 { + return Ok(Vec::new()); + } + + let output_slice = + unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; + + unsafe { + if !data_out.pbData.is_null() { + LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); + } + } + + Ok(output_slice.to_vec()) +} diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 2edd0e89616..125cb1bb567 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -35,7 +35,7 @@ function buildProxyBin(target, release = true) { const targetArg = target ? `--target ${target}` : ""; const releaseArg = release ? "--release" : ""; child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - + if (target) { // Copy the resulting binary to the dist folder const targetFolder = release ? "release" : "debug"; diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 8f2a5cb78a9..9e8404ea8dc 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,6 +17,7 @@ manual_test = [] anyhow = { workspace = true } autotype = { path = "../autotype" } base64 = { workspace = true } +bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } desktop_core = { path = "../core" } hex = { workspace = true } log = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 5ea75bd6120..a920f0c00aa 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -208,6 +208,30 @@ export declare namespace logging { } export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void } +export declare namespace chromium_importer { + export interface ProfileInfo { + id: string + name: string + } + export interface Login { + url: string + username: string + password: string + note: string + } + export interface LoginImportFailure { + url: string + username: string + error: string + } + export interface LoginImportResult { + login?: Login + failure?: LoginImportFailure + } + export function getInstalledBrowsers(): Promise> + export function getAvailableProfiles(browser: string): Promise> + export function importLogins(browser: string, profileId: string): Promise> +} export declare namespace autotype { export function getForegroundWindowTitle(): string export function typeInput(input: Array): void diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 24d41bc3831..1f99c1c3ed2 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -879,6 +879,96 @@ pub mod logging { } } +#[napi] +pub mod chromium_importer { + use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; + use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; + + #[napi(object)] + pub struct ProfileInfo { + pub id: String, + pub name: String, + } + + #[napi(object)] + pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, + } + + #[napi(object)] + pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, + } + + #[napi(object)] + pub struct LoginImportResult { + pub login: Option, + pub failure: Option, + } + + impl From<_LoginImportResult> for LoginImportResult { + fn from(l: _LoginImportResult) -> Self { + match l { + _LoginImportResult::Success(l) => LoginImportResult { + login: Some(Login { + url: l.url, + username: l.username, + password: l.password, + note: l.note, + }), + failure: None, + }, + _LoginImportResult::Failure(l) => LoginImportResult { + login: None, + failure: Some(LoginImportFailure { + url: l.url, + username: l.username, + error: l.error, + }), + }, + } + } + } + + impl From<_ProfileInfo> for ProfileInfo { + fn from(p: _ProfileInfo) -> Self { + ProfileInfo { + id: p.folder, + name: p.name, + } + } + } + + #[napi] + pub fn get_installed_browsers() -> napi::Result> { + bitwarden_chromium_importer::chromium::get_installed_browsers() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub fn get_available_profiles(browser: String) -> napi::Result> { + bitwarden_chromium_importer::chromium::get_available_profiles(&browser) + .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn import_logins( + browser: String, + profile_id: String, + ) -> napi::Result> { + bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id) + .await + .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} + #[napi] pub mod autotype { #[napi] diff --git a/apps/desktop/src/app/tools/import/chromium-importer.service.ts b/apps/desktop/src/app/tools/import/chromium-importer.service.ts new file mode 100644 index 00000000000..56f31c359db --- /dev/null +++ b/apps/desktop/src/app/tools/import/chromium-importer.service.ts @@ -0,0 +1,22 @@ +import { ipcMain } from "electron"; + +import { chromium_importer } from "@bitwarden/desktop-napi"; + +export class ChromiumImporterService { + constructor() { + ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => { + return await chromium_importer.getInstalledBrowsers(); + }); + + ipcMain.handle("chromium_importer.getAvailableProfiles", async (event, browser: string) => { + return await chromium_importer.getAvailableProfiles(browser); + }); + + ipcMain.handle( + "chromium_importer.importLogins", + async (event, browser: string, profileId: string) => { + return await chromium_importer.importLogins(browser, profileId); + }, + ); + } +} diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html index 2bb715b5a46..796d61e1b69 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.html +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -5,6 +5,8 @@ (formLoading)="this.loading = $event" (formDisabled)="this.disabled = $event" (onSuccessfulImport)="this.onSuccessfulImport($event)" + [onImportFromBrowser]="this.onImportFromBrowser" + [onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser" > diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.ts b/apps/desktop/src/app/tools/import/import-desktop.component.ts index c1639c6d3ec..f096471f770 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.ts +++ b/apps/desktop/src/app/tools/import/import-desktop.component.ts @@ -28,4 +28,12 @@ export class ImportDesktopComponent { protected async onSuccessfulImport(organizationId: string): Promise { this.dialogRef.close(); } + + protected onLoadProfilesFromBrowser(browser: string): Promise { + return ipc.tools.chromiumImporter.getAvailableProfiles(browser); + } + + protected onImportFromBrowser(browser: string, profile: string): Promise { + return ipc.tools.chromiumImporter.importLogins(browser, profile); + } } diff --git a/apps/desktop/src/app/tools/preload.ts b/apps/desktop/src/app/tools/preload.ts new file mode 100644 index 00000000000..574c27ac9fd --- /dev/null +++ b/apps/desktop/src/app/tools/preload.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from "electron"; + +const chromiumImporter = { + getInstalledBrowsers: (): Promise => + ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"), + getAvailableProfiles: (browser: string): Promise => + ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser), + importLogins: (browser: string, profileId: string): Promise => + ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId), +}; + +export default { + chromiumImporter, +}; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b2005a37472..62da68c8124 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3588,6 +3588,12 @@ "awaitingSSODesc": { "message": "Please continue to log in using your company credentials." }, + "importDirectlyFromBrowser": { + "message": "Import directly from browser" + }, + "browserProfile": { + "message": "Browser Profile" + }, "seeDetailedInstructions": { "message": "See detailed instructions on our help site at", "description": "This is followed a by a hyperlink to the help website." diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6d5f536fadb..d5484213a90 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ import { } from "@bitwarden/state-internal"; import { SerializedMemoryStorageService, StorageServiceProvider } from "@bitwarden/storage-core"; +import { ChromiumImporterService } from "./app/tools/import/chromium-importer.service"; import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service"; import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service"; import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; @@ -300,6 +301,8 @@ export class Main { this.ssoUrlService, ); + new ChromiumImporterService(); + this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain); void this.nativeAutofillMain.init(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0fb2db37518..90e8e64138f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import { contextBridge } from "electron"; +import tools from "./app/tools/preload"; import auth from "./auth/preload"; import autofill from "./autofill/preload"; import keyManagement from "./key-management/preload"; @@ -21,6 +22,7 @@ export const ipc = { autofill, platform, keyManagement, + tools, }; contextBridge.exposeInMainWorld("ipc", ipc); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d5f1f5f4fd7..30644b95627 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -37,6 +37,7 @@ export enum FeatureFlag { /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", + UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -79,6 +80,7 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, + [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, diff --git a/libs/common/src/tools/providers.spec.ts b/libs/common/src/tools/providers.spec.ts new file mode 100644 index 00000000000..5953e5ebab2 --- /dev/null +++ b/libs/common/src/tools/providers.spec.ts @@ -0,0 +1,178 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigService } from "../platform/abstractions/config/config.service"; +import { LogService } from "../platform/abstractions/log.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { StateProvider } from "../platform/state"; + +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; +import { ExtensionService } from "./extension/extension.service"; +import { disabledSemanticLoggerProvider } from "./log"; +import { createSystemServiceProvider } from "./providers"; + +describe("SystemServiceProvider", () => { + let mockEncryptor: LegacyEncryptorProvider; + let mockState: StateProvider; + let mockPolicy: PolicyService; + let mockRegistry: ExtensionRegistry; + let mockLogger: LogService; + let mockEnvironment: MockProxy; + let mockConfigService: ConfigService; + + beforeEach(() => { + jest.resetAllMocks(); + + mockEncryptor = mock(); + mockState = mock(); + mockPolicy = mock(); + mockRegistry = mock(); + mockLogger = mock(); + mockEnvironment = mock(); + mockConfigService = mock(); + }); + + describe("createSystemServiceProvider", () => { + it("returns object with all required services when called with valid parameters", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result).toHaveProperty("policy", mockPolicy); + expect(result).toHaveProperty("extension"); + expect(result).toHaveProperty("log"); + expect(result).toHaveProperty("configService", mockConfigService); + expect(result).toHaveProperty("environment", mockEnvironment); + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + it("creates ExtensionService with correct dependencies when called", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + describe("given development environment", () => { + it("uses enableLogForTypes when environment.isDev() returns true", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).not.toBe(disabledSemanticLoggerProvider); + }); + }); + + describe("given production environment", () => { + it("uses disabledSemanticLoggerProvider when environment.isDev() returns false", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).toBe(disabledSemanticLoggerProvider); + }); + }); + + it("configures ExtensionService with encryptor, state, log provider, and now function when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + const dateSpy = jest.spyOn(Date, "now"); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + expect(dateSpy).not.toHaveBeenCalled(); + }); + + it("passes through policy service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.policy).toBe(mockPolicy); + }); + + it("passes through configService correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.configService).toBe(mockConfigService); + }); + + it("passes through environment service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.environment).toBe(mockEnvironment); + }); + }); +}); diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index 181df94be83..ac42c556042 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -1,10 +1,15 @@ +import { LogService } from "@bitwarden/logging"; import { BitwardenClient } from "@bitwarden/sdk-internal"; +import { StateProvider } from "@bitwarden/state"; import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; import { ConfigService } from "../platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; import { ExtensionService } from "./extension/extension.service"; -import { LogProvider } from "./log"; +import { disabledSemanticLoggerProvider, enableLogForTypes, LogProvider } from "./log"; /** Provides access to commonly-used cross-cutting services. */ export type SystemServiceProvider = { @@ -20,6 +25,42 @@ export type SystemServiceProvider = { /** Config Service to determine flag features */ readonly configService: ConfigService; + /** Platform Service to inspect runtime environment */ + readonly environment: PlatformUtilsService; + /** SDK Service */ - readonly sdk: BitwardenClient; + readonly sdk?: BitwardenClient; }; + +/** Constructs a system service provider. */ +export function createSystemServiceProvider( + encryptor: LegacyEncryptorProvider, + state: StateProvider, + policy: PolicyService, + registry: ExtensionRegistry, + logger: LogService, + environment: PlatformUtilsService, + configService: ConfigService, +): SystemServiceProvider { + let log: LogProvider; + if (environment.isDev()) { + log = enableLogForTypes(logger, []); + } else { + log = disabledSemanticLoggerProvider; + } + + const extension = new ExtensionService(registry, { + encryptor, + state, + log, + now: Date.now, + }); + + return { + policy, + extension, + log, + configService, + environment, + }; +} diff --git a/libs/importer/src/components/chrome/import-chrome.component.html b/libs/importer/src/components/chrome/import-chrome.component.html new file mode 100644 index 00000000000..284f8cec857 --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.html @@ -0,0 +1,8 @@ +
+ + {{ "browserProfile" | i18n }} + + + + +
diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts new file mode 100644 index 00000000000..035487fea6f --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -0,0 +1,167 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { + AsyncValidatorFn, + ControlContainer, + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import * as papa from "papaparse"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + CalloutModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { ImportType } from "../../models"; + +@Component({ + selector: "import-chrome", + templateUrl: "import-chrome.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CalloutModule, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + SelectModule, + ], +}) +export class ImportChromeComponent implements OnInit, OnDestroy { + private _parentFormGroup: FormGroup; + protected formGroup = this.formBuilder.group({ + profile: [ + "", + { + nonNullable: true, + validators: [Validators.required], + asyncValidators: [this.validateAndEmitData()], + updateOn: "submit", + }, + ], + }); + + profileList: { id: string; name: string }[] = []; + + @Input() + format: ImportType; + + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + + @Output() csvDataLoaded = new EventEmitter(); + + constructor( + private formBuilder: FormBuilder, + private controlContainer: ControlContainer, + private logService: LogService, + private i18nService: I18nService, + ) {} + + async ngOnInit(): Promise { + this._parentFormGroup = this.controlContainer.control as FormGroup; + this._parentFormGroup.addControl("chromeOptions", this.formGroup); + this.profileList = await this.onLoadProfilesFromBrowser(this.getBrowserName()); + } + + ngOnDestroy(): void { + this._parentFormGroup.removeControl("chromeOptions"); + } + + /** + * Attempts to login to the provided Chrome email and retrieve account contents. + * Will return a validation error if unable to login or fetch. + * Emits account contents to `csvDataLoaded` + */ + validateAndEmitData(): AsyncValidatorFn { + return async () => { + try { + const logins = await this.onImportFromBrowser( + this.getBrowserName(), + this.formGroup.controls.profile.value, + ); + if (logins.length === 0) { + throw "nothing to import"; + } + const chromeLogins: ChromeLogin[] = []; + for (const l of logins) { + if (l.login != null) { + chromeLogins.push(new ChromeLogin(l.login)); + } + } + const csvData = papa.unparse(chromeLogins); + this.csvDataLoaded.emit(csvData); + return null; + } catch (error) { + this.logService.error(`Chromium importer error: ${error}`); + return { + errors: { + message: this.i18nService.t(this.getValidationErrorI18nKey(error)), + }, + }; + } + }; + } + + private getValidationErrorI18nKey(error: any): string { + const message = typeof error === "string" ? error : error?.message; + switch (message) { + default: + return "errorOccurred"; + } + } + + private getBrowserName(): string { + if (this.format === "edgecsv") { + return "Microsoft Edge"; + } else if (this.format === "operacsv") { + return "Opera"; + } else if (this.format === "bravecsv") { + return "Brave"; + } else if (this.format === "vivaldicsv") { + return "Vivaldi"; + } + return "Chrome"; + } +} + +class ChromeLogin { + name: string; + url: string; + username: string; + password: string; + note: string; + + constructor(login: any) { + const url = Utils.getUrl(login?.url); + if (url != null) { + this.name = new URL(url).hostname; + } + if (this.name == null) { + this.name = login.url; + } + this.url = login.url; + this.username = login.username; + this.password = login.password; + this.note = login.note; + } +} diff --git a/libs/importer/src/components/chrome/index.ts b/libs/importer/src/components/chrome/index.ts new file mode 100644 index 00000000000..1365c155038 --- /dev/null +++ b/libs/importer/src/components/chrome/index.ts @@ -0,0 +1 @@ +export { ImportChromeComponent } from "./import-chrome.component"; diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index d0107bb5808..9f1247b52da 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -169,27 +169,41 @@ "Export" to save the JSON file.
--> - + The process is exactly the same as importing from Google Chrome. - See detailed instructions on our help site at - - https://bitwarden.com/help/import-from-chrome/ + See detailed instructions on our help site at + + https://bitwarden.com/help/import-from-chrome/ +

+ + + {{ "importDirectlyFromBrowser" | i18n }} + + + {{ "importFromCSV" | i18n }} + +
See detailed instructions on our help site at @@ -440,12 +454,20 @@ previously chosen. - -
+ @if (showLastPassOptions) { + + } @else if (showChromiumOptions$ | async) { + + } @else { {{ "selectImportFile" | i18n }}
@@ -473,7 +495,7 @@ formControlName="fileContents" > -
+ } diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 646db8d643e..774392be879 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, + DestroyRef, EventEmitter, Inject, Input, @@ -13,17 +14,23 @@ import { Output, ViewChild, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; -import { Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs"; +import { + Observable, + Subject, + lastValueFrom, + combineLatest, + firstValueFrom, + BehaviorSubject, +} from "rxjs"; import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/operators"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, @@ -34,14 +41,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; 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 { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -62,49 +65,20 @@ import { ToastService, LinkModule, } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; +import { ImporterMetadata, DataLoader, Loader, Instructions } from "../metadata"; import { ImportOption, ImportResult, ImportType } from "../models"; -import { - ImportApiService, - ImportApiServiceAbstraction, - ImportCollectionServiceAbstraction, - ImportService, - ImportServiceAbstraction, -} from "../services"; +import { ImportCollectionServiceAbstraction, ImportServiceAbstraction } from "../services"; +import { ImportChromeComponent } from "./chrome"; import { FilePasswordPromptComponent, ImportErrorDialogComponent, ImportSuccessDialogComponent, } from "./dialog"; +import { ImporterProviders } from "./importer-providers"; import { ImportLastPassComponent } from "./lastpass"; -const safeProviders: SafeProvider[] = [ - safeProvider({ - provide: ImportApiServiceAbstraction, - useClass: ImportApiService, - deps: [ApiService], - }), - safeProvider({ - provide: ImportServiceAbstraction, - useClass: ImportService, - deps: [ - CipherService, - FolderService, - ImportApiServiceAbstraction, - I18nService, - CollectionService, - KeyService, - EncryptService, - PinServiceAbstraction, - AccountService, - SdkService, - RestrictedItemTypesService, - ], - }), -]; - @Component({ selector: "tools-import", templateUrl: "import.component.html", @@ -118,6 +92,7 @@ const safeProviders: SafeProvider[] = [ SelectModule, CalloutModule, ReactiveFormsModule, + ImportChromeComponent, ImportLastPassComponent, RadioButtonModule, CardComponent, @@ -125,7 +100,7 @@ const safeProviders: SafeProvider[] = [ SectionComponent, LinkModule, ], - providers: safeProviders, + providers: ImporterProviders, }) export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { featuredImportOptions: ImportOption[]; @@ -160,6 +135,12 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + protected organization: Organization; protected destroy$ = new Subject(); @@ -184,6 +165,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { fileContents: [], file: [], lastPassType: ["direct" as "csv" | "direct"], + // FIXME: once the flag is disabled this should initialize to `Strategy.browser` + chromiumLoader: [Loader.file as DataLoader], }); @ViewChild(BitSubmitDirective) @@ -208,6 +191,26 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + private importer$ = new BehaviorSubject(undefined); + + /** emits `true` when the chromium instruction block should be visible. */ + protected readonly showChromiumInstructions$ = this.importer$.pipe( + map((importer) => importer?.instructions === Instructions.chromium), + ); + + /** emits `true` when direct browser import is available. */ + // FIXME: use the capabilities list to populate `chromiumLoader` and replace the explicit + // strategy check with a check for multiple loaders + protected readonly browserImporterAvailable$ = this.importer$.pipe( + map((importer) => (importer?.loaders ?? []).includes(Loader.chromium)), + ); + + /** emits `true` when the chromium loader is selected. */ + protected readonly showChromiumOptions$ = + this.formGroup.controls.chromiumLoader.valueChanges.pipe( + map((chromiumLoader) => chromiumLoader === Loader.chromium), + ); + constructor( protected i18nService: I18nService, protected importService: ImportServiceAbstraction, @@ -226,6 +229,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { protected toastService: ToastService, protected accountService: AccountService, private restrictedItemTypesService: RestrictedItemTypesService, + private destroyRef: DestroyRef, ) {} protected get importBlockedByPolicy(): boolean { @@ -246,6 +250,23 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { async ngOnInit() { this.setImportOptions(); + this.importService + .metadata$(this.formGroup.controls.format.valueChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (importer) => { + this.importer$.next(importer); + + // when an importer is defined, the loader needs to be set to a value from + // its list. + const loader = importer.loaders.includes(Loader.chromium) + ? Loader.chromium + : importer.loaders?.[0]; + this.formGroup.controls.chromiumLoader.setValue(loader ?? Loader.file); + }, + error: (err: unknown) => this.logService.error("an error occurred", err), + }); + if (this.organizationId) { await this.handleOrganizationImportInit(); } else { @@ -578,7 +599,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private async setImportContents(): Promise { const fileEl = document.getElementById("import_input_file") as HTMLInputElement; - const files = fileEl.files; + const files = fileEl?.files; let fileContents = this.formGroup.controls.fileContents.value; if (files != null && files.length > 0) { diff --git a/libs/importer/src/components/importer-providers.ts b/libs/importer/src/components/importer-providers.ts new file mode 100644 index 00000000000..b00bd65211e --- /dev/null +++ b/libs/importer/src/components/importer-providers.ts @@ -0,0 +1,91 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +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 { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; +import { + createSystemServiceProvider, + SystemServiceProvider, +} from "@bitwarden/common/tools/providers"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { KeyService } from "@bitwarden/key-management"; +import { StateProvider } from "@bitwarden/state"; +import { SafeInjectionToken } from "@bitwarden/ui-common"; + +import { + ImportApiService, + ImportApiServiceAbstraction, + ImportService, + ImportServiceAbstraction, +} from "../services"; + +// FIXME: unify with `SYSTEM_SERVICE_PROVIDER` when migrating it from the generator component module +// to a general module. +const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); + +/** Import service factories */ +export const ImporterProviders: SafeProvider[] = [ + safeProvider({ + provide: ImportApiServiceAbstraction, + useClass: ImportApiService, + deps: [ApiService], + }), + safeProvider({ + provide: LegacyEncryptorProvider, + useClass: KeyServiceLegacyEncryptorProvider, + deps: [EncryptService, KeyService], + }), + safeProvider({ + provide: ExtensionRegistry, + useFactory: () => { + return buildExtensionRegistry(); + }, + deps: [], + }), + safeProvider({ + provide: SYSTEM_SERVICE_PROVIDER, + useFactory: createSystemServiceProvider, + deps: [ + LegacyEncryptorProvider, + StateProvider, + PolicyService, + ExtensionRegistry, + LogService, + PlatformUtilsService, + ConfigService, + ], + }), + safeProvider({ + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherService, + FolderService, + ImportApiServiceAbstraction, + I18nService, + CollectionService, + KeyService, + EncryptService, + PinServiceAbstraction, + AccountService, + RestrictedItemTypesService, + SYSTEM_SERVICE_PROVIDER, + ], + }), +]; diff --git a/libs/importer/src/importers/chrome-csv-importer.ts b/libs/importer/src/importers/chrome-csv-importer.ts index 445f0ad57ae..c7a72c126b0 100644 --- a/libs/importer/src/importers/chrome-csv-importer.ts +++ b/libs/importer/src/importers/chrome-csv-importer.ts @@ -24,6 +24,7 @@ export class ChromeCsvImporter extends BaseImporter implements Importer { cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); cipher.login.uris = this.makeUriArray(value.url); + cipher.notes = this.getValueOrDefault(value.note); this.cleanupCipher(cipher); result.ciphers.push(cipher); }); diff --git a/libs/importer/src/metadata/availability.ts b/libs/importer/src/metadata/availability.ts new file mode 100644 index 00000000000..0ac7269496a --- /dev/null +++ b/libs/importer/src/metadata/availability.ts @@ -0,0 +1,15 @@ +import { ClientType } from "@bitwarden/client-type"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { Loader } from "./data"; +import { DataLoader } from "./types"; + +/** Describes which loaders are supported on each client */ +export const LoaderAvailability: Record = deepFreeze({ + [Loader.chromium]: [ClientType.Desktop], + [Loader.download]: [ClientType.Browser], + [Loader.file]: [ClientType.Browser, ClientType.Desktop, ClientType.Web, ClientType.Cli], + + // FIXME: enable IPC importer on `ClientType.Desktop` once it's ready + [Loader.ipc]: [], +}); diff --git a/libs/importer/src/metadata/data.ts b/libs/importer/src/metadata/data.ts new file mode 100644 index 00000000000..82edd5cdc2d --- /dev/null +++ b/libs/importer/src/metadata/data.ts @@ -0,0 +1,27 @@ +/** Mechanisms that load data into the importer. */ +export const Loader = Object.freeze({ + /** Data loaded from a file provided by the user/ */ + file: "file", + + /** Data loaded directly from the chromium browser's data store */ + chromium: "chromium", + + /** Data provided through an importer ipc channel (e.g. Bitwarden bridge) */ + ipc: "ipc", + + /** Data provided through direct file download (e.g. a LastPass export) */ + download: "download", +}); + +/** Re-branded products often leave their exporters unaltered; when that occurs, + * `Instructions` lets us group them together. + * + * @remarks Instructions values must be mutually exclusive from Loader's values. + */ +export const Instructions = Object.freeze({ + /** the instructions are unique to the import type */ + unique: "unique", + + /** shared chromium instructions */ + chromium: "chromium", +}); diff --git a/libs/importer/src/metadata/importers.ts b/libs/importer/src/metadata/importers.ts new file mode 100644 index 00000000000..efd5eafe7d5 --- /dev/null +++ b/libs/importer/src/metadata/importers.ts @@ -0,0 +1,27 @@ +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { ImportType } from "../models"; + +import { Loader, Instructions } from "./data"; +import { ImporterMetadata } from "./types"; + +// FIXME: load this data from rust code +const importers = [ + // chromecsv import depends upon operating system, so ironically it doesn't support chromium + { id: "chromecsv", loaders: [Loader.file], instructions: Instructions.chromium }, + { id: "operacsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { + id: "vivaldicsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + { id: "bravecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { id: "edgecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + + // FIXME: add other formats and remove `Partial` from export +] as const; + +/** Describes which loaders are available for each import type */ +export const Importers: Partial> = deepFreeze( + Object.fromEntries(importers.map((i) => [i.id, i])), +); diff --git a/libs/importer/src/metadata/index.ts b/libs/importer/src/metadata/index.ts new file mode 100644 index 00000000000..17c009ae68e --- /dev/null +++ b/libs/importer/src/metadata/index.ts @@ -0,0 +1,4 @@ +export * from "./availability"; +export * from "./data"; +export * from "./types"; +export * from "./importers"; diff --git a/libs/importer/src/metadata/types.ts b/libs/importer/src/metadata/types.ts new file mode 100644 index 00000000000..09b3fe97fd5 --- /dev/null +++ b/libs/importer/src/metadata/types.ts @@ -0,0 +1,20 @@ +import { ImportType } from "../models"; + +import { Instructions, Loader } from "./data"; + +/** Mechanisms that load data into the importer. */ +export type DataLoader = (typeof Loader)[keyof typeof Loader]; + +export type InstructionLink = (typeof Instructions)[keyof typeof Instructions]; + +/** Mechanisms that load data into the importer. */ +export type ImporterMetadata = { + /** Identifies the importer */ + type: ImportType; + + /** Identifies the instructions for the importer; this defaults to `unique`. */ + instructions?: InstructionLink; + + /** Describes the strategies used to obtain imported data */ + loaders: DataLoader[]; +}; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 205dbaf0198..22a4f63b248 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -6,7 +6,7 @@ export interface ImportOption { export const featuredImportOptions = [ { id: "bitwardenjson", name: "Bitwarden (json)" }, { id: "bitwardencsv", name: "Bitwarden (csv)" }, - { id: "chromecsv", name: "Chrome (csv)" }, + { id: "chromecsv", name: "Chrome" }, { id: "dashlanecsv", name: "Dashlane (csv)" }, { id: "firefoxcsv", name: "Firefox (csv)" }, { id: "keepass2xml", name: "KeePass 2 (xml)" }, @@ -46,9 +46,10 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, - { id: "edgecsv", name: "Edge (csv)" }, - { id: "operacsv", name: "Opera (csv)" }, - { id: "vivaldicsv", name: "Vivaldi (csv)" }, + { id: "edgecsv", name: "Edge" }, + { id: "operacsv", name: "Opera" }, + { id: "vivaldicsv", name: "Vivaldi" }, + { id: "bravecsv", name: "Brave" }, { id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" }, { id: "blurcsv", name: "Blur (csv)" }, { id: "passwordagentcsv", name: "Password Agent (csv)" }, diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index d869dc71cc7..ee0d1ed33ab 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Observable } from "rxjs"; + // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { Importer } from "../importers/importer"; +import { ImporterMetadata } from "../metadata"; import { ImportOption, ImportType } from "../models/import-options"; import { ImportResult } from "../models/import-result"; @@ -13,6 +16,10 @@ export abstract class ImportServiceAbstraction { featuredImportOptions: readonly ImportOption[]; regularImportOptions: readonly ImportOption[]; getImportOptions: () => ImportOption[]; + + /** describes the features supported by a format */ + metadata$: (type$: Observable) => Observable; + import: ( importer: Importer, fileContents: string, diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index ad6e6ebf016..c3d555af936 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,14 +1,20 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, Subject, firstValueFrom } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { ClientType } from "@bitwarden/client-type"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { CollectionId, OrganizationId } 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"; @@ -19,6 +25,8 @@ import { KeyService } from "@bitwarden/key-management"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Instructions, Loader } from "../metadata"; +import { ImportType } from "../models"; import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "./import-api.service.abstraction"; @@ -35,8 +43,8 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; - let sdkService: MockSdkService; let restrictedItemTypesService: MockProxy; + let systemServiceProvider: MockProxy; beforeEach(() => { cipherService = mock(); @@ -47,9 +55,20 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); - sdkService = new MockSdkService(); restrictedItemTypesService = mock(); + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(false)); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue({ debug: jest.fn() }), + }); + importService = new ImportService( cipherService, folderService, @@ -60,8 +79,8 @@ describe("ImportService", () => { encryptService, pinService, accountService, - sdkService, restrictedItemTypesService, + systemServiceProvider, ); }); @@ -249,6 +268,170 @@ describe("ImportService", () => { expect(importResult.folderRelationships[1]).toEqual([0, 1]); }); }); + + describe("metadata$", () => { + let featureFlagSubject: BehaviorSubject; + let typeSubject: Subject; + let mockLogger: { debug: jest.Mock }; + + beforeEach(() => { + featureFlagSubject = new BehaviorSubject(false); + typeSubject = new Subject(); + mockLogger = { debug: jest.fn() }; + + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue(mockLogger), + }); + + // Recreate the service with the updated mocks for logging tests + importService = new ImportService( + cipherService, + folderService, + importApiService, + i18nService, + collectionService, + keyService, + encryptService, + pinService, + accountService, + restrictedItemTypesService, + systemServiceProvider, + ); + }); + + afterEach(() => { + featureFlagSubject.complete(); + typeSubject.complete(); + }); + + it("should emit metadata when type$ emits", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result).toEqual({ + type: testType, + loaders: expect.any(Array), + instructions: Instructions.chromium, + }); + expect(result.type).toBe(testType); + }); + + it("should include all loaders when chromium feature flag is enabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(true); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should exclude chromium loader when feature flag is disabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(false); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).not.toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should update when type$ changes", async () => { + const emissions: ImporterMetadata[] = []; + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next("chromecsv"); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].type).toBe("chromecsv"); + expect(emissions[1].type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should update when feature flag changes", async () => { + const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next(testType); + featureFlagSubject.next(true); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].loaders).not.toContain(Loader.chromium); + expect(emissions[1].loaders).toContain(Loader.chromium); + + subscription.unsubscribe(); + }); + + it("should update when both type$ and feature flag change", async () => { + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + // Initial emission + typeSubject.next("chromecsv"); + + // Change both at the same time + featureFlagSubject.next(true); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions.length).toBeGreaterThanOrEqual(2); + const lastEmission = emissions[emissions.length - 1]; + expect(lastEmission.type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should log debug information with correct data", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + await metadataPromise; + + expect(mockLogger.debug).toHaveBeenCalledWith( + { importType: testType, capabilities: expect.any(Object) }, + "capabilities updated", + ); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 133607251c3..e868a5ac516 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -10,6 +10,7 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; @@ -17,8 +18,9 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { OrganizationId } 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"; @@ -95,6 +97,7 @@ import { PasswordDepot17XmlImporter, } from "../importers"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Importers, Loader } from "../metadata"; import { featuredImportOptions, ImportOption, @@ -104,12 +107,15 @@ import { import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction"; import { ImportServiceAbstraction } from "../services/import.service.abstraction"; +import { availableLoaders as availableLoaders } from "../util"; export class ImportService implements ImportServiceAbstraction { featuredImportOptions = featuredImportOptions as readonly ImportOption[]; regularImportOptions = regularImportOptions as readonly ImportOption[]; + private logger: SemanticLogger; + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -120,14 +126,42 @@ export class ImportService implements ImportServiceAbstraction { private encryptService: EncryptService, private pinService: PinServiceAbstraction, private accountService: AccountService, - private sdkService: SdkService, private restrictedItemTypesService: RestrictedItemTypesService, - ) {} + private system: SystemServiceProvider, + ) { + this.logger = system.log({ type: "ImportService" }); + } getImportOptions(): ImportOption[] { return this.featuredImportOptions.concat(this.regularImportOptions); } + metadata$(type$: Observable): Observable { + const browserEnabled$ = this.system.configService.getFeatureFlag$( + FeatureFlag.UseChromiumImporter, + ); + const client = this.system.environment.getClientType(); + const capabilities$ = combineLatest([type$, browserEnabled$]).pipe( + map(([type, enabled]) => { + let loaders = availableLoaders(type, client); + if (!enabled) { + loaders = loaders?.filter((loader) => loader !== Loader.chromium); + } + + const capabilities: ImporterMetadata = { type, loaders }; + if (type in Importers) { + capabilities.instructions = Importers[type].instructions; + } + + this.logger.debug({ importType: type, capabilities }, "capabilities updated"); + + return capabilities; + }), + ); + + return capabilities$; + } + async import( importer: Importer, fileContents: string, @@ -260,6 +294,7 @@ export class ImportService implements ImportServiceAbstraction { case "chromecsv": case "operacsv": case "vivaldicsv": + case "bravecsv": return new ChromeCsvImporter(); case "firefoxcsv": return new FirefoxCsvImporter(); diff --git a/libs/importer/src/util.spec.ts b/libs/importer/src/util.spec.ts new file mode 100644 index 00000000000..5a68e3cea12 --- /dev/null +++ b/libs/importer/src/util.spec.ts @@ -0,0 +1,60 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { Loader } from "./metadata"; +import { availableLoaders } from "./util"; + +describe("availableLoaders", () => { + describe("given valid import types", () => { + it("returns available loaders when client supports all loaders", () => { + const result = availableLoaders("operacsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + + it("returns filtered loaders when client supports some loaders", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns single loader for import types with one loader", () => { + const result = availableLoaders("chromecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file]); + }); + + it("returns all supported loaders for multi-loader import types", () => { + const result = availableLoaders("bravecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + }); + + describe("given unknown import types", () => { + it("returns undefined when import type is not found in metadata", () => { + const result = availableLoaders("nonexistent" as any, ClientType.Desktop); + + expect(result).toBeUndefined(); + }); + }); + + describe("given different client types", () => { + it("returns appropriate loaders for Browser client", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for Web client", () => { + const result = availableLoaders("chromecsv", ClientType.Web); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for CLI client", () => { + const result = availableLoaders("vivaldicsv", ClientType.Cli); + + expect(result).toEqual([Loader.file]); + }); + }); +}); diff --git a/libs/importer/src/util.ts b/libs/importer/src/util.ts new file mode 100644 index 00000000000..0a76b7e753b --- /dev/null +++ b/libs/importer/src/util.ts @@ -0,0 +1,19 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { LoaderAvailability, Importers } from "./metadata"; +import { ImportType } from "./models"; + +/** Lookup the loaders supported by a specific client. + * WARNING: this method does not supply metadata for every import type. + * @returns `undefined` when metadata is not defined for the type, or + * an array identifying the supported clients. + */ +export function availableLoaders(type: ImportType, client: ClientType) { + if (!(type in Importers)) { + return undefined; + } + + const capabilities = Importers[type]?.loaders ?? []; + const available = capabilities.filter((loader) => LoaderAvailability[loader].includes(client)); + return available; +} From 866f56f2d5015e5642f56656a8df0c6daae25bd1 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:47:39 -0400 Subject: [PATCH 19/25] fix(login-component): [PM-24151] 'Message' property may be null or undefined. (#16225) --- libs/auth/src/angular/login/login.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 546bb13e883..b6941682236 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -266,7 +266,7 @@ export class LoginComponent implements OnInit, OnDestroy { if (error instanceof ErrorResponse) { switch (error.statusCode) { case HttpStatusCode.BadRequest: { - if (error.message.toLowerCase().includes("username or password is incorrect")) { + if (error.message?.toLowerCase().includes("username or password is incorrect")) { this.formGroup.controls.masterPassword.setErrors({ error: { message: this.i18nService.t("invalidMasterPassword"), From 8c7faf49d5a5798145739b672d814629aa0a4350 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 4 Sep 2025 20:14:04 +0530 Subject: [PATCH 20/25] Billing/pm 23385 premium modal in web after registration (#16182) * create the pricing library * Create pricing-card.component * Refactor the code * feat: Add pricing card component library * Fix the test failing error * Address billing pr comments * feat: Add Storybook documentation and stories for pricing-card component * Fix some ui feedback * Changes from the display and sizes * feat(billing): refactor pricing card with flexible title slots and active badge * Enhance pricing card with flexible design and button icons * refactor: organize pricing card files into dedicated folder * Complete pricing card enhancements with Chromatic feedback fixes * refactor base on pr coments * Fix the button alignment * Update all the card to have the same height * Fix the slot issue on the title * Fix the Lint format issue * Add the header in the stories book --- .github/CODEOWNERS | 1 + .storybook/main.ts | 2 + libs/pricing/README.md | 5 + libs/pricing/jest.config.js | 16 + libs/pricing/package.json | 21 + libs/pricing/project.json | 33 ++ .../pricing-card/pricing-card.component.html | 85 ++++ .../pricing-card/pricing-card.component.mdx | 228 +++++++++++ .../pricing-card.component.spec.ts | 194 ++++++++++ .../pricing-card.component.stories.ts | 365 ++++++++++++++++++ .../pricing-card/pricing-card.component.ts | 42 ++ libs/pricing/src/index.ts | 2 + libs/pricing/src/pricing.spec.ts | 8 + libs/pricing/test.setup.ts | 28 ++ libs/pricing/tsconfig.json | 13 + libs/pricing/tsconfig.lib.json | 10 + libs/pricing/tsconfig.spec.json | 10 + package-lock.json | 8 + tailwind.config.js | 1 + tsconfig.base.json | 1 + 20 files changed, 1073 insertions(+) create mode 100644 libs/pricing/README.md create mode 100644 libs/pricing/jest.config.js create mode 100644 libs/pricing/package.json create mode 100644 libs/pricing/project.json create mode 100644 libs/pricing/src/components/pricing-card/pricing-card.component.html create mode 100644 libs/pricing/src/components/pricing-card/pricing-card.component.mdx create mode 100644 libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts create mode 100644 libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts create mode 100644 libs/pricing/src/components/pricing-card/pricing-card.component.ts create mode 100644 libs/pricing/src/index.ts create mode 100644 libs/pricing/src/pricing.spec.ts create mode 100644 libs/pricing/test.setup.ts create mode 100644 libs/pricing/tsconfig.json create mode 100644 libs/pricing/tsconfig.lib.json create mode 100644 libs/pricing/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c190a77068d..154dcb0f72e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -216,3 +216,4 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev +libs/pricing @bitwarden/team-billing-dev diff --git a/.storybook/main.ts b/.storybook/main.ts index 879e87fe376..d3811bb178d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -10,6 +10,8 @@ const config: StorybookConfig = { "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/dirt/card/src/**/*.mdx", "../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/pricing/src/**/*.mdx", + "../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/tools/send/send-ui/src/**/*.mdx", "../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.mdx", diff --git a/libs/pricing/README.md b/libs/pricing/README.md new file mode 100644 index 00000000000..600dd64f713 --- /dev/null +++ b/libs/pricing/README.md @@ -0,0 +1,5 @@ +# pricing + +Owned by: billing + +Components and services that facilitate the retrieval and display of Bitwarden's pricing. diff --git a/libs/pricing/jest.config.js b/libs/pricing/jest.config.js new file mode 100644 index 00000000000..2aa2bfa8287 --- /dev/null +++ b/libs/pricing/jest.config.js @@ -0,0 +1,16 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../tsconfig.base"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + displayName: "libs/pricing tests", + setupFilesAfterEnv: ["/test.setup.ts"], + coverageDirectory: "../../coverage/libs/pricing", + moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/../../", + }), +}; diff --git a/libs/pricing/package.json b/libs/pricing/package.json new file mode 100644 index 00000000000..9d5ec85c1bc --- /dev/null +++ b/libs/pricing/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bitwarden/pricing", + "version": "0.0.0", + "description": "Components and services that facilitate the retrieval and display of Bitwarden's pricing.", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch" + }, + "private": true +} diff --git a/libs/pricing/project.json b/libs/pricing/project.json new file mode 100644 index 00000000000..7e6e154bceb --- /dev/null +++ b/libs/pricing/project.json @@ -0,0 +1,33 @@ +{ + "name": "pricing", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/pricing/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/pricing", + "main": "libs/pricing/src/index.ts", + "tsConfig": "libs/pricing/tsconfig.lib.json", + "assets": ["libs/pricing/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/pricing/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/pricing/jest.config.js" + } + } + } +} diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html new file mode 100644 index 00000000000..d0ac4fc519f --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -0,0 +1,85 @@ +
+ +
+ + + + @if (activeBadge(); as activeBadgeValue) { + + {{ activeBadgeValue.text }} + + } +
+ + +
+

+ {{ tagline() }} +

+
+ + + @if (price(); as priceValue) { +
+
+ ${{ priceValue.amount }} + + / {{ priceValue.cadence }} + @if (priceValue.showPerUser) { + per user + } + +
+
+ } + + +
+ @if (button(); as buttonConfig) { + + } +
+ + +
+ @if (features(); as featureList) { + @if (featureList.length > 0) { +
    + @for (feature of featureList; track feature) { +
  • + + {{ + feature + }} +
  • + } +
+ } + } +
+
diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx new file mode 100644 index 00000000000..355ca71eb80 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx @@ -0,0 +1,228 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs"; +import * as PricingCardStories from "./pricing-card.component.stories"; + + + +# Pricing Card + +A reusable UI component for displaying pricing plans with consistent styling and behavior across +Bitwarden applications. + + + +## Usage + +The pricing card component is designed to be used in billing and subscription interfaces to display +different pricing tiers and plans. + +```ts +import { PricingCardComponent } from "@bitwarden/pricing"; +``` + +```html + +

Premium Plan

+
+``` + +## API + +### Inputs + +| Input | Type | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | +| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | +| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | +| `features` | `string[]` | **Optional.** List of features with checkmarks | +| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | + +### Content Slots + +| Slot | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| `slot="title"` | **Required.** HTML element with `slot="title"` attribute for the title with appropriate heading level and styling | + +### Events + +| Event | Type | Description | +| ------------- | ------ | ----------------------------------------- | +| `buttonClick` | `void` | Emitted when the action button is clicked | + +## Flexibility + +The title slot allows complete control over the heading element and styling: + +```html + +

Main Plan

+

Sub Plan

+ + +

Featured Plan

+``` + +| Output | Type | Description | +| ------------- | ------ | --------------------------------------- | +| `buttonClick` | `void` | Emitted when the plan button is clicked | + +## Design + +The component follows the Bitwarden design system with: + +- **Fixed width**: 449px for consistent layout +- **Border & Elevation**: secondary-100 border with shadow-sm elevation +- **Border radius**: 24px (tw-rounded-3xl) for modern appearance +- **Spacing**: 32px padding (tw-p-8) around content +- **Modern Angular**: Uses `@if`, `@for`, and `@switch` control flow with signal inputs +- **Signal inputs**: Type-safe inputs using Angular's signal-based input API +- **Official buttons**: Uses `bitButton` directive from Component Library +- **Typography**: Uses `bitTypography` helper and custom 30px price styling +- **Icons**: Uses `bwi-check` icon with primary-600 styling from the legacy icon library +- **Layout**: Flexbox column layout with `tw-h-full` for equal height alignment in grid layouts +- **Accessibility**: Configurable heading levels and semantic structure + +## Examples + +### Basic Plan (No Price) + +For free or contact-based plans, omit the `price` input: + + + +```html + + +``` + +### Business Plan with Per User Pricing + +Show business plans with "per user" text: + + + +```html + + +``` + +### Annual Pricing + +Show annual pricing with different cadence: + + + +```html + + +``` + +### Configurable Heading Levels + +For accessibility, you can configure the heading level: + + + +```html + + + + + + + +``` + +### Disabled State + +For coming soon or unavailable plans: + + + +```html + + +``` + +### Pricing Grid Layout + +Multiple cards displayed together: + + + +## Button Types + +The component supports all standard button types from the Component Library: + +- `primary` - Main call-to-action (blue background, white text) +- `secondary` - Secondary action (transparent background, blue text) +- `danger` - Destructive actions (red theme) +- `unstyled` - Text-only button + +## Do's and Don'ts + +### ✅ Do + +- Use consistent button text like "Choose [Plan]" or "Get Started" +- Keep taglines concise and focused on key benefits +- Use annual pricing to show value (e.g., "2 months free") +- Group related plans together with consistent styling + +### ❌ Don't + +- Make taglines longer than 2 lines (they will be truncated) +- Use custom button styling - rely on the built-in types +- Mix different pricing cadences in the same comparison +- Override the 449px width - it's designed for optimal layout + +## Accessibility + +The component includes: + +- Proper heading hierarchy (`h3` for titles) +- Semantic button elements with `type="button"` +- Screen reader friendly structure +- Focus management and keyboard navigation +- High contrast color combinations diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts new file mode 100644 index 00000000000..ed2c28d8cb3 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -0,0 +1,194 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; + +import { PricingCardComponent } from "./pricing-card.component"; + +@Component({ + template: ` + + +

{{ titleText }}

+ +

{{ titleText }}

+ +

{{ titleText }}

+ +

{{ titleText }}

+ +
{{ titleText }}
+ +
{{ titleText }}
+
+
+ `, + imports: [PricingCardComponent, CommonModule, TypographyModule], +}) +class TestHostComponent { + titleText = "Test Plan"; + tagline = "A great plan for testing"; + price: { amount: number; cadence: "monthly" | "annually"; showPerUser?: boolean } = { + amount: 10, + cadence: "monthly", + }; + button: { type: ButtonType; text: string; disabled?: boolean } = { + text: "Select Plan", + type: "primary", + }; + features = ["Feature 1", "Feature 2", "Feature 3"]; + titleLevel: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h3"; + activeBadge: { text: string; variant?: string } | undefined = undefined; + + onButtonClick() { + // Test method + } +} + +describe("PricingCardComponent", () => { + let component: PricingCardComponent; + let fixture: ComponentFixture; + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + PricingCardComponent, + TestHostComponent, + IconModule, + TypographyModule, + CommonModule, + ], + }).compileComponents(); + + // For signal inputs, we need to set required inputs through the host component + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + + fixture = TestBed.createComponent(PricingCardComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should display title and tagline", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + // Test that the component renders and shows the tagline (which is an input, not projected content) + expect(compiled.querySelector("p").textContent).toContain("A great plan for testing"); + // Note: Title testing is skipped due to content projection limitations in Angular testing + }); + + it("should display price when provided", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("$10"); + expect(compiled.textContent).toContain("/ monthly"); + }); + + it("should display features when provided", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("Feature 1"); + expect(compiled.textContent).toContain("Feature 2"); + expect(compiled.textContent).toContain("Feature 3"); + }); + + it("should emit buttonClick when button is clicked", () => { + jest.spyOn(hostComponent, "onButtonClick"); + hostFixture.detectChanges(); + + const button = hostFixture.nativeElement.querySelector("button"); + button.click(); + + expect(hostComponent.onButtonClick).toHaveBeenCalled(); + }); + + it("should work without optional inputs", () => { + hostComponent.price = undefined as any; + hostComponent.features = undefined as any; + hostComponent.button = undefined as any; + + hostFixture.detectChanges(); + + // Note: Title content projection testing skipped due to Angular testing limitations + expect(hostFixture.nativeElement.querySelector("button")).toBeFalsy(); + }); + + it("should display per user text when showPerUser is true", () => { + hostComponent.price = { amount: 5, cadence: "monthly", showPerUser: true }; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("$5"); + expect(compiled.textContent).toContain("per user"); + }); + + it("should use configurable heading level", () => { + hostComponent.titleLevel = "h2"; + hostFixture.detectChanges(); + + // Note: Content projection testing for configurable headings is covered in Storybook + // Angular unit tests have limitations with content projection testing + expect(component).toBeTruthy(); // Basic smoke test + }); + + it("should display bwi-check icons for features", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const icons = compiled.querySelectorAll("i.bwi-check"); + + expect(icons.length).toBe(3); // One for each feature + }); + + it("should not display button when button input is not provided", () => { + hostComponent.button = undefined as any; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.querySelector("button")).toBeFalsy(); + }); + + it("should display active badge when activeBadge is provided", () => { + hostComponent.activeBadge = { text: "Current Plan" }; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + const badge = compiled.querySelector("span[bitBadge]"); + expect(badge).toBeTruthy(); + expect(badge.textContent.trim()).toBe("Current Plan"); + }); + + it("should not display active badge when activeBadge is not provided", () => { + hostComponent.activeBadge = undefined; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.querySelector("span[bitBadge]")).toBeFalsy(); + }); + + it("should have proper layout structure with flexbox", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).toContain("tw-flex"); + expect(cardContainer.classList).toContain("tw-flex-col"); + expect(cardContainer.classList).toContain("tw-size-full"); + expect(cardContainer.classList).not.toContain("tw-block"); // Should not have conflicting display property + }); +}); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts new file mode 100644 index 00000000000..832345de357 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts @@ -0,0 +1,365 @@ +import { Meta, StoryObj } from "@storybook/angular"; + +import { TypographyModule } from "@bitwarden/components"; + +import { PricingCardComponent } from "./pricing-card.component"; + +export default { + title: "Billing/Pricing Card", + component: PricingCardComponent, + moduleMetadata: { + imports: [TypographyModule], + }, + args: { + tagline: "Everything you need for secure password management across all your devices", + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium-Upgrade-flows--pricing-increase-?node-id=858-44276&t=KjcXRRvf8PXJI51j-0", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + +

Premium Plan

+
+ `, + }), + args: { + tagline: "Everything you need for secure password management across all your devices", + price: { amount: 10, cadence: "monthly" }, + button: { text: "Choose Premium", type: "primary" }, + features: [ + "Unlimited passwords and passkeys", + "Secure password sharing", + "Integrated 2FA authenticator", + "Advanced 2FA options", + "Priority customer support", + ], + }, +}; + +export const WithoutPrice: Story = { + render: (args) => ({ + props: args, + template: ` + +

Free Plan

+
+ `, + }), + args: { + tagline: "Get started with essential password management features", + button: { text: "Get Started", type: "secondary" }, + features: ["Store unlimited passwords", "Access from any device", "Secure password generator"], + }, +}; + +export const WithoutFeatures: Story = { + render: (args) => ({ + props: args, + template: ` + +

Enterprise Plan

+
+ `, + }), + args: { + tagline: "Advanced security and management for your organization", + price: { amount: 3, cadence: "monthly" }, + button: { text: "Contact Sales", type: "primary" }, + }, +}; + +export const Annual: Story = { + render: (args) => ({ + props: args, + template: ` + +

Premium Plan

+
+ `, + }), + args: { + tagline: "Save more with annual billing", + price: { amount: 120, cadence: "annually" }, + button: { text: "Choose Annual", type: "primary" }, + features: [ + "All Premium features", + "2 months free with annual billing", + "Priority customer support", + ], + }, +}; + +export const Disabled: Story = { + render: (args) => ({ + props: args, + template: ` + +

Coming Soon

+
+ `, + }), + args: { + tagline: "This plan will be available soon with exciting new features", + price: { amount: 15, cadence: "monthly" }, + button: { text: "Coming Soon", type: "secondary", disabled: true }, + features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], + }, +}; + +export const LongTagline: Story = { + render: (args) => ({ + props: args, + template: ` + +

Business Plan

+
+ `, + }), + args: { + tagline: + "Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business", + price: { amount: 5, cadence: "monthly", showPerUser: true }, + button: { text: "Start Business Trial", type: "primary" }, + features: [ + "Everything in Premium", + "Admin dashboard", + "Team reporting", + "Advanced permissions", + "SSO integration", + ], + }, +}; + +export const AllButtonTypes: Story = { + render: () => ({ + template: ` +
+ +

Primary Button

+
+ + +

Secondary Button

+
+ + +

Danger Button

+
+ + +

Unstyled Button

+
+
+ `, + props: {}, + }), +}; + +export const ConfigurableHeadings: Story = { + render: () => ({ + template: ` +
+ +

H2 Heading

+
+ + +

H4 Heading

+
+
+ `, + props: {}, + }), +}; + +export const PricingGrid: Story = { + render: () => ({ + template: ` +
+ +

Free

+
+ + +

Premium

+
+ + +

Business

+
+
+ `, + props: {}, + }), +}; + +export const WithoutButton: Story = { + render: (args) => ({ + props: args, + template: ` + +

Coming Soon Plan

+
+ `, + }), + args: { + tagline: "This plan will be available soon with exciting new features", + price: { amount: 15, cadence: "monthly" }, + features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], + }, +}; + +export const ActivePlan: Story = { + render: (args) => ({ + props: args, + template: ` + +

Free

+
+ `, + }), + args: { + tagline: "Your current plan with essential password management features", + features: ["Store unlimited passwords", "Access from any device", "Secure password generator"], + activeBadge: { text: "Active plan" }, + }, +}; + +export const PricingComparison: Story = { + render: () => ({ + template: ` +
+
+ +

Free

+
+
+ +
+ +

Premium

+
+
+ +
+ +

Business

+
+
+
+ `, + props: {}, + }), +}; + +export const WithButtonIcon: Story = { + render: () => ({ + template: ` +
+ + +

Premium

+
+ + + +

Business

+
+
+ `, + props: {}, + }), +}; diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts new file mode 100644 index 00000000000..b727fb10673 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -0,0 +1,42 @@ +import { Component, EventEmitter, input, Output } from "@angular/core"; + +import { + BadgeModule, + BadgeVariant, + ButtonModule, + ButtonType, + IconModule, + TypographyModule, +} from "@bitwarden/components"; + +/** + * A reusable UI-only component that displays pricing information in a card format. + * This component has no external dependencies and performs no logic - it only displays data + * and emits events when the button is clicked. + */ +@Component({ + selector: "billing-pricing-card", + templateUrl: "./pricing-card.component.html", + imports: [BadgeModule, ButtonModule, IconModule, TypographyModule], +}) +export class PricingCardComponent { + tagline = input.required(); + price = input<{ amount: number; cadence: "monthly" | "annually"; showPerUser?: boolean }>(); + button = input<{ + type: ButtonType; + text: string; + disabled?: boolean; + icon?: { type: string; position: "before" | "after" }; + }>(); + features = input(); + activeBadge = input<{ text: string; variant?: BadgeVariant }>(); + + @Output() buttonClick = new EventEmitter(); + + /** + * Handles button click events and emits the buttonClick event + */ + onButtonClick(): void { + this.buttonClick.emit(); + } +} diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts new file mode 100644 index 00000000000..9eeb2de518d --- /dev/null +++ b/libs/pricing/src/index.ts @@ -0,0 +1,2 @@ +// Components +export * from "./components/pricing-card/pricing-card.component"; diff --git a/libs/pricing/src/pricing.spec.ts b/libs/pricing/src/pricing.spec.ts new file mode 100644 index 00000000000..3b66c8f0e6e --- /dev/null +++ b/libs/pricing/src/pricing.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("pricing", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/pricing/test.setup.ts b/libs/pricing/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/pricing/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); diff --git a/libs/pricing/tsconfig.json b/libs/pricing/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/pricing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/pricing/tsconfig.lib.json b/libs/pricing/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/pricing/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/pricing/tsconfig.spec.json b/libs/pricing/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/pricing/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index 7c99ed85173..4bd1238b27e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -392,6 +392,10 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/pricing": { + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/serialization": { "name": "@bitwarden/serialization", "version": "0.0.1", @@ -4681,6 +4685,10 @@ "resolved": "libs/platform", "link": true }, + "node_modules/@bitwarden/pricing": { + "resolved": "libs/pricing", + "link": true + }, "node_modules/@bitwarden/sdk-internal": { "version": "0.2.0-main.266", "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.266.tgz", diff --git a/tailwind.config.js b/tailwind.config.js index dff04c897c3..bb0489ed10f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,6 +8,7 @@ config.content = [ "./libs/billing/src/**/*.{html,ts,mdx}", "./libs/assets/src/**/*.{html,ts}", "./libs/platform/src/**/*.{html,ts,mdx}", + "./libs/pricing/src/**/*.{html,ts,mdx}", "./libs/tools/send/send-ui/src/*.{html,ts,mdx}", "./libs/vault/src/**/*.{html,ts,mdx}", "./apps/web/src/**/*.{html,ts,mdx}", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3d1d2915f67..3f903558f70 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -50,6 +50,7 @@ "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/platform/*": ["./libs/platform/src/*"], + "@bitwarden/pricing": ["libs/pricing/src/index.ts"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], "@bitwarden/serialization": ["libs/serialization/src/index.ts"], "@bitwarden/state": ["libs/state/src/index.ts"], From 7247f4987e6c3e0ecdcbf1b7a429b722904177db Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 4 Sep 2025 11:07:52 -0400 Subject: [PATCH 21/25] [PM-22313] Refactor organization vault component (#16017) * refactor organization, userId, and filter * refactor collections * refactor allGroups to observable * Refactor ciphers WIP * fix filter$ * refactor collections$, refresh$, isEmpty$, proccesingEvents$ * resolve remaining ts-strict errors * refactor *ngIf to @if syntax * rename function * clean up * fix issues from merge conflict * better error handling, clean up * wip add feature flag * refactor org vault: improve null safety & loading * add take(2) to firstLoadComplete observable * add real feature flag * cleanup * fix icon * Add comments * refactor org vault with null checks, update util function * fix type --------- Co-authored-by: Thomas Rittson --- .../deprecated_vault.component.html | 156 ++ .../collections/deprecated_vault.component.ts | 1491 +++++++++++++++++ .../collections/vault-routing.module.ts | 23 +- .../collections/vault.component.html | 324 ++-- .../collections/vault.component.ts | 971 ++++++----- .../organizations/collections/vault.module.ts | 4 +- .../organization.service.abstraction.ts | 3 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/vault/utils/cipher-view-like-utils.ts | 12 + 9 files changed, 2373 insertions(+), 613 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html create mode 100644 apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html new file mode 100644 index 00000000000..1122f10e8f7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html @@ -0,0 +1,156 @@ + + + + + + + {{ freeTrial.message }} + + {{ "clickHereToAddPaymentMethod" | i18n }} + + + + + + {{ resellerWarning?.message }} + + + + + +
+
+ +
+
+ + + {{ "all" | i18n }} + + + + {{ "addAccess" | i18n }} + + + + {{ trashCleanupWarning }} + + + + + + {{ "noItemsInList" | i18n }} + + + + + +
+ + {{ "loading" | i18n }} +
+
+
diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts new file mode 100644 index 00000000000..42900a0b941 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts @@ -0,0 +1,1491 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { ActivatedRoute, Params, Router } from "@angular/router"; +import { + BehaviorSubject, + combineLatest, + firstValueFrom, + from, + lastValueFrom, + Observable, + of, + Subject, +} from "rxjs"; +import { + catchError, + concatMap, + debounceTime, + distinctUntilChanged, + filter, + first, + map, + shareReplay, + switchMap, + takeUntil, + tap, +} from "rxjs/operators"; + +import { + CollectionAdminService, + CollectionAdminView, + CollectionService, + CollectionView, + Unassigned, +} from "@bitwarden/admin-console/common"; +import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; +import { Search } from "@bitwarden/assets/svg"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; +import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { + BannerModule, + DialogRef, + DialogService, + NoItemsModule, + ToastService, +} from "@bitwarden/components"; +import { + AttachmentDialogResult, + AttachmentsV2Component, + CipherFormConfig, + CipherFormConfigService, + CollectionAssignmentResult, + DecryptionFailureDialogComponent, + PasswordRepromptService, +} from "@bitwarden/vault"; +import { + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, +} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; + +import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; +import { + ResellerWarning, + ResellerWarningService, +} from "../../../billing/services/reseller-warning.service"; +import { TrialFlowService } from "../../../billing/services/trial-flow.service"; +import { FreeTrial } from "../../../billing/types/free-trial"; +import { SharedModule } from "../../../shared"; +import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; +import { + VaultItemDialogComponent, + VaultItemDialogMode, + VaultItemDialogResult, +} from "../../../vault/components/vault-item-dialog/vault-item-dialog.component"; +import { VaultItemEvent } from "../../../vault/components/vault-items/vault-item-event"; +import { VaultItemsModule } from "../../../vault/components/vault-items/vault-items.module"; +import { + BulkDeleteDialogResult, + openBulkDeleteDialog, +} from "../../../vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; +import { VaultFilterService } from "../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; +import { RoutedVaultFilterBridgeService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; +import { RoutedVaultFilterService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; +import { createFilterFunction } from "../../../vault/individual-vault/vault-filter/shared/models/filter-function"; +import { + All, + RoutedVaultFilterModel, +} from "../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; +import { VaultFilter } from "../../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; +import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; +import { GroupApiService, GroupView } from "../core"; +import { openEntityEventsDialog } from "../manage/entity-events.component"; +import { + CollectionDialogAction, + CollectionDialogTabType, + openCollectionDialog, +} from "../shared/components/collection-dialog"; + +import { + BulkCollectionsDialogComponent, + BulkCollectionsDialogResult, +} from "./bulk-collections-dialog"; +import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; +import { getFlatCollectionTree, getNestedCollectionTree } from "./utils"; +import { VaultFilterModule } from "./vault-filter/vault-filter.module"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; + +const BroadcasterSubscriptionId = "OrgVaultComponent"; +const SearchTextDebounceInterval = 200; + +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +enum AddAccessStatusType { + All = 0, + AddAccess = 1, +} + +@Component({ + selector: "app-org-vault", + templateUrl: "deprecated_vault.component.html", + imports: [ + VaultHeaderComponent, + CollectionAccessRestrictedComponent, + VaultFilterModule, + VaultItemsModule, + SharedModule, + BannerModule, + NoItemsModule, + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, + ], + providers: [ + RoutedVaultFilterService, + RoutedVaultFilterBridgeService, + { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, + ], +}) +export class VaultComponent implements OnInit, OnDestroy { + protected Unassigned = Unassigned; + + trashCleanupWarning: string = null; + activeFilter: VaultFilter = new VaultFilter(); + + protected showAddAccessToggle = false; + protected noItemIcon = Search; + protected performingInitialLoad = true; + protected refreshing = false; + protected processingEvent = false; + protected filter: RoutedVaultFilterModel = {}; + protected organization: Organization; + protected allCollections: CollectionAdminView[]; + protected allGroups: GroupView[]; + protected ciphers: CipherView[]; + protected collections: CollectionAdminView[]; + protected selectedCollection: TreeNode | undefined; + protected isEmpty: boolean; + protected showCollectionAccessRestricted: boolean; + private hasSubscription$ = new BehaviorSubject(false); + protected currentSearchText$: Observable; + protected useOrganizationWarningsService$: Observable; + protected freeTrialWhenWarningsServiceDisabled$: Observable; + protected resellerWarningWhenWarningsServiceDisabled$: Observable; + protected prevCipherId: string | null = null; + protected userId: UserId; + /** + * A list of collections that the user can assign items to and edit those items within. + * @protected + */ + protected editableCollections$: Observable; + protected allCollectionsWithoutUnassigned$: Observable; + + protected get hideVaultFilters(): boolean { + return this.organization?.isProviderUser && !this.organization?.isMember; + } + + private searchText$ = new Subject(); + private refresh$ = new BehaviorSubject(null); + private destroy$ = new Subject(); + protected addAccessStatus$ = new BehaviorSubject(0); + private vaultItemDialogRef?: DialogRef | undefined; + + @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; + + private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe( + map((account) => account?.id), + switchMap((id) => + this.organizationService.organizations$(id).pipe( + filter((organizations) => organizations.length === 1), + map(([organization]) => organization), + switchMap((organization) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + tap((organizationMetaData) => { + this.hasSubscription$.next(organizationMetaData.hasSubscription); + }), + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ), + ), + ); + + constructor( + private route: ActivatedRoute, + private organizationService: OrganizationService, + protected vaultFilterService: VaultFilterService, + private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, + private routedVaultFilterService: RoutedVaultFilterService, + private router: Router, + private changeDetectorRef: ChangeDetectorRef, + private syncService: SyncService, + private i18nService: I18nService, + private dialogService: DialogService, + private messagingService: MessagingService, + private broadcasterService: BroadcasterService, + private ngZone: NgZone, + private platformUtilsService: PlatformUtilsService, + private cipherService: CipherService, + private passwordRepromptService: PasswordRepromptService, + private collectionAdminService: CollectionAdminService, + private searchService: SearchService, + private searchPipe: SearchPipe, + private groupService: GroupApiService, + private logService: LogService, + private eventCollectionService: EventCollectionService, + private totpService: TotpService, + private apiService: ApiService, + private toastService: ToastService, + private configService: ConfigService, + private cipherFormConfigService: CipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, + protected billingApiService: BillingApiServiceAbstraction, + private organizationBillingService: OrganizationBillingServiceAbstraction, + private resellerWarningService: ResellerWarningService, + private accountService: AccountService, + private billingNotificationService: BillingNotificationService, + private organizationWarningsService: OrganizationWarningsService, + private collectionService: CollectionService, + ) {} + + async ngOnInit() { + this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + this.trashCleanupWarning = this.i18nService.t( + this.platformUtilsService.isSelfHost() + ? "trashCleanupWarningSelfHosted" + : "trashCleanupWarning", + ); + + const filter$ = this.routedVaultFilterService.filter$; + + // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault, + // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here, + // but really we should change to using our own vault filter model that only represents valid states in AC. + const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId => + value !== Unassigned; + const organizationId$ = filter$.pipe( + map((filter) => filter.organizationId), + filter((filter) => filter !== undefined), + filter(isOrganizationId), + distinctUntilChanged(), + ); + + const organization$ = this.accountService.activeAccount$.pipe( + map((account) => account?.id), + switchMap((id) => + organizationId$.pipe( + switchMap((organizationId) => + this.organizationService + .organizations$(id) + .pipe(map((organizations) => organizations.find((org) => org.id === organizationId))), + ), + takeUntil(this.destroy$), + shareReplay({ refCount: false, bufferSize: 1 }), + ), + ), + ); + + const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe( + first(), + switchMap(async ([organization]) => { + this.organization = organization; + + if (!organization.canEditAnyCollection) { + await this.syncService.fullSync(false); + } + + return undefined; + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + // 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.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + if (message.successfully) { + this.refresh(); + this.changeDetectorRef.detectChanges(); + } + break; + } + }); + }); + + this.routedVaultFilterBridgeService.activeFilter$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeFilter) => { + this.activeFilter = activeFilter; + + // watch the active filters. Only show toggle when viewing the collections filter + if (!this.activeFilter.collectionId) { + this.showAddAccessToggle = false; + } + }); + + this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$)) + .subscribe((searchText) => + this.router.navigate([], { + queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, + queryParamsHandling: "merge", + replaceUrl: true, + }), + ); + + this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + + this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( + switchMap(() => organizationId$), + switchMap((orgId) => + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), + ), + ), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( + map((collections) => { + // Users that can edit all ciphers can implicitly add to / edit within any collection + if (this.organization.canEditAllCiphers) { + return collections; + } + return collections.filter((c) => c.assigned); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const allCollections$ = combineLatest([ + organizationId$, + this.allCollectionsWithoutUnassigned$, + ]).pipe( + map(([organizationId, allCollections]) => { + // FIXME: We should not assert that the Unassigned type is a CollectionId. + // Instead we should consider representing the Unassigned collection as a different object, given that + // it is not actually a collection. + return allCollections.concat( + new CollectionAdminView({ + name: this.i18nService.t("unassigned"), + id: Unassigned as CollectionId, + organizationId, + }), + ); + }), + ); + + const allGroups$ = organizationId$.pipe( + switchMap((organizationId) => this.groupService.getAll(organizationId)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe( + switchMap(async ([organization]) => { + // If user swaps organization reset the addAccessToggle + if (!this.showAddAccessToggle || organization) { + this.addAccessToggle(0); + } + let ciphers; + + // Restricted providers (who are not members) do not have access org cipher endpoint below + // Return early to avoid 404 response + if (!organization.isMember && organization.isProviderUser) { + return []; + } + + // If the user can edit all ciphers for the organization then fetch them ALL. + if (organization.canEditAllCiphers) { + ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + ciphers?.forEach((c) => (c.edit = true)); + } else { + // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). + ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); + } + + await this.searchService.indexCiphers(this.userId, ciphers, organization.id); + return ciphers; + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const allCipherMap$ = allCiphers$.pipe( + map((ciphers) => { + return Object.fromEntries(ciphers.map((c) => [c.id, c])); + }), + ); + + const nestedCollections$ = allCollections$.pipe( + map((collections) => getNestedCollectionTree(collections)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const collections$ = combineLatest([ + nestedCollections$, + filter$, + this.currentSearchText$, + this.addAccessStatus$, + ]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + concatMap(async ([collections, filter, searchText, addAccessStatus]) => { + if ( + filter.collectionId === Unassigned || + (filter.collectionId === undefined && filter.type !== undefined) + ) { + return []; + } + + this.showAddAccessToggle = false; + let searchableCollectionNodes: TreeNode[] = []; + if (filter.collectionId === undefined || filter.collectionId === All) { + searchableCollectionNodes = collections; + } else { + const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( + collections, + filter.collectionId, + ); + searchableCollectionNodes = selectedCollection?.children ?? []; + } + + let collectionsToReturn: CollectionAdminView[] = []; + + if (await this.searchService.isSearchable(this.userId, searchText)) { + // Flatten the tree for searching through all levels + const flatCollectionTree: CollectionAdminView[] = + getFlatCollectionTree(searchableCollectionNodes); + + collectionsToReturn = this.searchPipe.transform( + flatCollectionTree, + searchText, + (collection) => collection.name, + (collection) => collection.id, + ); + } else { + collectionsToReturn = searchableCollectionNodes.map( + (treeNode: TreeNode): CollectionAdminView => treeNode.node, + ); + } + + // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit + this.showAddAccessToggle = + !this.organization.allowAdminAccessToAllCollectionItems && + this.organization.canEditUnmanagedCollections && + collectionsToReturn.some((c) => c.unmanaged); + + if (addAccessStatus === 1 && this.showAddAccessToggle) { + collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); + } + return collectionsToReturn; + }), + takeUntil(this.destroy$), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter]) => { + if ( + filter.collectionId === undefined || + filter.collectionId === All || + filter.collectionId === Unassigned + ) { + return undefined; + } + + return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const showCollectionAccessRestricted$ = combineLatest([ + filter$, + selectedCollection$, + organization$, + ]).pipe( + map(([filter, collection, organization]) => { + return ( + (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || + (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) + ); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const ciphers$ = combineLatest([ + allCiphers$, + filter$, + this.currentSearchText$, + showCollectionAccessRestricted$, + ]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => { + if (filter.collectionId === undefined && filter.type === undefined) { + return []; + } + + if (showCollectionAccessRestricted) { + // Do not show ciphers for restricted collections + // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible + return []; + } + + const filterFunction = createFilterFunction(filter); + + if (await this.searchService.isSearchable(this.userId, searchText)) { + return await this.searchService.searchCiphers( + this.userId, + searchText, + [filterFunction], + ciphers, + ); + } + + return ciphers.filter(filterFunction); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + firstSetup$ + .pipe( + switchMap(() => combineLatest([this.route.queryParams, allCipherMap$])), + filter(() => this.vaultItemDialogRef == undefined), + switchMap(async ([qParams, allCiphersMap]) => { + const cipherId = getCipherIdFromParams(qParams); + + if (!cipherId) { + this.prevCipherId = null; + return; + } + + if (cipherId === this.prevCipherId) { + return; + } + + this.prevCipherId = cipherId; + + const cipher = allCiphersMap[cipherId]; + if (cipher) { + let action = qParams.action; + + if (action == "showFailedToDecrypt") { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [cipherId as CipherId], + }); + await this.router.navigate([], { + queryParams: { itemId: null, cipherId: null, action: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + return; + } + + // Default to "view" + if (action == null) { + action = "view"; + } + + if (action === "view") { + await this.viewCipherById(cipher); + } else { + await this.editCipher(cipher, false); + } + } else { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unknownCipher"), + }); + await this.router.navigate([], { + queryParams: { cipherId: null, itemId: null }, + queryParamsHandling: "merge", + }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + firstSetup$ + .pipe( + switchMap(() => combineLatest([this.route.queryParams, organization$, allCiphers$])), + switchMap(async ([qParams, organization, allCiphers$]) => { + const cipherId = qParams.viewEvents; + if (!cipherId) { + return; + } + const cipher = allCiphers$.find((c) => c.id === cipherId); + if (organization.useEvents && cipher != undefined) { + await this.viewEvents(cipher); + } else { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unknownCipher"), + }); + await this.router.navigate([], { + queryParams: { viewEvents: null }, + queryParamsHandling: "merge", + }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + // Billing Warnings + this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$( + FeatureFlag.UseOrganizationWarningsService, + ); + + this.useOrganizationWarningsService$ + .pipe( + switchMap((enabled) => + enabled + ? this.organizationWarningsService.showInactiveSubscriptionDialog$(this.organization) + : this.unpaidSubscriptionDialog$, + ), + takeUntil(this.destroy$), + ) + .subscribe(); + + organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntil(this.destroy$), + ) + .subscribe(); + + const freeTrial$ = combineLatest([ + organization$, + this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), + ]).pipe( + filter( + ([org, hasSubscription]) => org.isOwner && hasSubscription && org.canViewBillingHistory, + ), + switchMap(([org]) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + from(this.organizationBillingService.getPaymentSource(org.id)).pipe( + catchError((error: unknown) => { + this.billingNotificationService.handleError(error); + return of(null); + }), + ), + ]), + ), + map(([org, sub, paymentSource]) => + this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource), + ), + filter((result) => result !== null), + ); + + this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( + filter((enabled) => !enabled), + switchMap(() => freeTrial$), + ); + + const resellerWarning$ = organization$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe( + map((metadata) => ({ org, metadata })), + ), + ), + map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)), + ); + + this.resellerWarningWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( + filter((enabled) => !enabled), + switchMap(() => resellerWarning$), + ); + // End Billing Warnings + + firstSetup$ + .pipe( + switchMap(() => this.refresh$), + tap(() => (this.refreshing = true)), + switchMap(() => + combineLatest([ + organization$, + filter$, + allCollections$, + allGroups$, + ciphers$, + collections$, + selectedCollection$, + showCollectionAccessRestricted$, + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe( + ([ + organization, + filter, + allCollections, + allGroups, + ciphers, + collections, + selectedCollection, + showCollectionAccessRestricted, + ]) => { + this.organization = organization; + this.filter = filter; + this.allCollections = allCollections; + this.allGroups = allGroups; + this.ciphers = ciphers; + this.collections = collections; + this.selectedCollection = selectedCollection; + this.showCollectionAccessRestricted = showCollectionAccessRestricted; + + this.isEmpty = collections?.length === 0 && ciphers?.length === 0; + + // This is a temporary fix to avoid double fetching collections. + // TODO: Remove when implementing new VVR menu + this.vaultFilterService.reloadCollections(allCollections); + + this.refreshing = false; + this.performingInitialLoad = false; + }, + ); + } + + async navigateToPaymentMethod() { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; + await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { + state: { launchPaymentModalAutomatically: true }, + }); + } + + addAccessToggle(e: AddAccessStatusType) { + this.addAccessStatus$.next(e); + } + + get loading() { + return this.refreshing || this.processingEvent; + } + + ngOnDestroy() { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.destroy$.next(); + this.destroy$.complete(); + } + + async onVaultItemsEvent(event: VaultItemEvent) { + this.processingEvent = true; + + try { + switch (event.type) { + case "viewAttachments": + await this.editCipherAttachments(event.item); + break; + case "clone": + await this.cloneCipher(event.item); + break; + case "restore": + if (event.items.length === 1) { + await this.restore(event.items[0]); + } else { + await this.bulkRestore(event.items); + } + break; + case "delete": { + const ciphers = event.items + .filter((i) => i.collection === undefined) + .map((i) => i.cipher); + const collections = event.items + .filter((i) => i.cipher === undefined) + .map((i) => i.collection); + if (ciphers.length === 1 && collections.length === 0) { + await this.deleteCipher(ciphers[0]); + } else if (ciphers.length === 0 && collections.length === 1) { + await this.deleteCollection(collections[0] as CollectionAdminView); + } else { + await this.bulkDelete(ciphers, collections, this.organization); + } + break; + } + case "copyField": + await this.copy(event.item, event.field); + break; + case "editCollection": + await this.editCollection( + event.item as CollectionAdminView, + CollectionDialogTabType.Info, + event.readonly, + ); + break; + case "viewCollectionAccess": + await this.editCollection( + event.item as CollectionAdminView, + CollectionDialogTabType.Access, + event.readonly, + ); + break; + case "bulkEditCollectionAccess": + await this.bulkEditCollectionAccess(event.items, this.organization); + break; + case "assignToCollections": + await this.bulkAssignToCollections(event.items); + break; + case "viewEvents": + await this.viewEvents(event.item); + break; + } + } finally { + this.processingEvent = false; + } + } + + filterSearchText(searchText: string) { + this.searchText$.next(searchText); + } + + async editCipherAttachments(cipher: CipherView) { + if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { + this.go({ cipherId: null, itemId: null }); + return; + } + + if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { + this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); + return; + } + + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: cipher.id as CipherId, + organizationId: cipher.organizationId as OrganizationId, + admin: true, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if ( + result.action === AttachmentDialogResult.Removed || + result.action === AttachmentDialogResult.Uploaded + ) { + this.refresh(); + } + } + + /** Opens the Add/Edit Dialog */ + async addCipher(cipherType?: CipherType) { + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + "add", + null, + cipherType, + ); + + const collectionId: CollectionId | undefined = this.activeFilter.collectionId as CollectionId; + + cipherFormConfig.initialValues = { + organizationId: this.organization.id as OrganizationId, + collectionIds: collectionId ? [collectionId] : [], + }; + + await this.openVaultItemDialog("form", cipherFormConfig); + } + + /** + * Edit the given cipher or add a new cipher + * @param cipherView - When set, the cipher to be edited + * @param cloneCipher - `true` when the cipher should be cloned. + */ + async editCipher(cipher: CipherView | null, cloneCipher: boolean) { + if ( + cipher && + cipher.reprompt !== 0 && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + // didn't pass password prompt, so don't open add / edit modal + this.go({ cipherId: null, itemId: null }); + return; + } + + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + cloneCipher ? "clone" : "edit", + cipher?.id as CipherId | null, + ); + + await this.openVaultItemDialog("form", cipherFormConfig, cipher); + } + + /** Opens the view dialog for the given cipher unless password reprompt fails */ + async viewCipherById(cipher: CipherView) { + if (!cipher) { + return; + } + + if ( + cipher && + cipher.reprompt !== 0 && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + // Didn't pass password prompt, so don't open add / edit modal. + await this.go({ cipherId: null, itemId: null, action: null }); + return; + } + + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + "edit", + cipher.id as CipherId, + cipher.type, + ); + + await this.openVaultItemDialog( + "view", + cipherFormConfig, + cipher, + this.activeFilter.collectionId as CollectionId, + ); + } + + /** + * Open the combined view / edit dialog for a cipher. + */ + async openVaultItemDialog( + 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` + const dialogMode = disableForm ? "view" : mode; + this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { + mode: dialogMode, + formConfig, + disableForm, + activeCollectionId, + isAdminConsoleAction: true, + restore: this.restore, + }); + + const result = await lastValueFrom(this.vaultItemDialogRef.closed); + this.vaultItemDialogRef = undefined; + + // If the dialog was closed by deleting the cipher, refresh the vault. + if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { + this.refresh(); + } + + // Clear the query params when the dialog closes + await this.go({ cipherId: null, itemId: null, action: null }); + } + + async cloneCipher(cipher: CipherView) { + if (cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + + await this.editCipher(cipher, true); + } + + restore = async (c: CipherView): Promise => { + if (!c.isDeleted) { + return; + } + + if ( + !this.organization.permissions.editAnyCollection && + !c.edit && + !this.organization.allowAdminAccessToAllCollectionItems + ) { + this.showMissingPermissionsError(); + return; + } + + if (!(await this.repromptCipher([c]))) { + return; + } + + // Allow restore of an Unassigned Item + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const asAdmin = this.organization?.canEditAnyCollection || c.isUnassigned; + await this.cipherService.restoreWithServer(c.id, activeUserId, asAdmin); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("restoredItem"), + }); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + }; + + async bulkRestore(ciphers: CipherView[]) { + if ( + !this.organization.permissions.editAnyCollection && + ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems) + ) { + this.showMissingPermissionsError(); + return; + } + + if (!(await this.repromptCipher(ciphers))) { + return; + } + + // assess if there are unassigned ciphers and/or editable ciphers selected in bulk for restore + const editAccessCiphers: string[] = []; + const unassignedCiphers: string[] = []; + + // If user has edit all Access no need to check for unassigned ciphers + if (this.organization.canEditAllCiphers) { + ciphers.map((cipher) => { + editAccessCiphers.push(cipher.id); + }); + } else { + ciphers.map((cipher) => { + if (cipher.collectionIds.length === 0) { + unassignedCiphers.push(cipher.id); + } else if (cipher.edit) { + editAccessCiphers.push(cipher.id); + } + }); + } + + if (unassignedCiphers.length === 0 && editAccessCiphers.length === 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) { + await this.cipherService.restoreManyWithServer( + [...unassignedCiphers, ...editAccessCiphers], + this.userId, + this.organization.id, + ); + } + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("restoredItems"), + }); + this.refresh(); + } + + async deleteCipher(c: CipherView): Promise { + if (!c.edit && !this.organization.canEditAllCiphers) { + this.showMissingPermissionsError(); + return; + } + + if (!(await this.repromptCipher([c]))) { + return; + } + + const permanent = c.isDeleted; + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, + content: { key: permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.deleteCipherWithServer(c.id, activeUserId, permanent, c.isUnassigned); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"), + }); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async deleteCollection(collection: CollectionAdminView): Promise { + if (!collection.canDelete(this.organization)) { + this.showMissingPermissionsError(); + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: collection.name, + content: { key: "deleteCollectionConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + try { + await this.apiService.deleteCollection(this.organization?.id, collection.id); + await this.collectionService.delete([collection.id as CollectionId], this.userId); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedCollectionId", collection.name), + }); + + // Clear the cipher cache to clear the deleted collection from the cipher state + await this.cipherService.clear(); + + // Navigate away if we deleted the collection we were viewing + if (this.selectedCollection?.node.id === collection.id) { + void this.router.navigate([], { + queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkDelete( + ciphers: CipherView[], + collections: CollectionView[], + organization: Organization, + ) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + // Allow bulk deleting of Unassigned Items + const unassignedCiphers: string[] = []; + const assignedCiphers: string[] = []; + + ciphers.map((c) => { + if (c.isUnassigned) { + unassignedCiphers.push(c.id); + } else { + assignedCiphers.push(c.id); + } + }); + + if (ciphers.length === 0 && collections.length === 0) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + const canDeleteCollections = + collections == null || collections.every((c) => c.canDelete(organization)); + const canDeleteCiphers = + ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers; + + if (!canDeleteCiphers || !canDeleteCollections) { + this.showMissingPermissionsError(); + return; + } + + const dialog = openBulkDeleteDialog(this.dialogService, { + data: { + permanent: this.filter.type === "trash", + cipherIds: assignedCiphers, + collections: collections, + organization, + unassignedCiphers, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkDeleteDialogResult.Deleted) { + this.refresh(); + } + } + + async copy(cipher: CipherView, field: "username" | "password" | "totp") { + let aType; + let value; + let typeI18nKey; + + if (field === "username") { + aType = "Username"; + value = cipher.login.username; + typeI18nKey = "username"; + } else if (field === "password") { + aType = "Password"; + value = cipher.login.password; + typeI18nKey = "password"; + } else if (field === "totp") { + aType = "TOTP"; + const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + value = totpResponse?.code; + typeI18nKey = "verificationCodeTotp"; + } else { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + return; + } + + if ( + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.repromptCipher([cipher])) + ) { + return; + } + + if (!cipher.viewPassword) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.toastService.showToast({ + variant: "info", + title: null, + message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)), + }); + + if (field === "password") { + await this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); + } else if (field === "totp") { + await this.eventCollectionService.collect( + EventType.Cipher_ClientCopiedHiddenField, + cipher.id, + ); + } + } + + async addCollection(): Promise { + const dialog = openCollectionDialog(this.dialogService, { + data: { + organizationId: this.organization?.id, + parentCollectionId: this.selectedCollection?.node.id, + limitNestedCollections: !this.organization.canEditAnyCollection, + isAdminConsoleActive: true, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if ( + result.action === CollectionDialogAction.Saved || + result.action === CollectionDialogAction.Deleted + ) { + this.refresh(); + } + } + + async editCollection( + c: CollectionAdminView, + tab: CollectionDialogTabType, + readonly: boolean, + ): Promise { + const dialog = openCollectionDialog(this.dialogService, { + data: { + collectionId: c?.id, + organizationId: this.organization?.id, + initialTab: tab, + readonly: readonly, + isAddAccessCollection: c.unmanaged, + limitNestedCollections: !this.organization.canEditAnyCollection, + isAdminConsoleActive: true, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if ( + result.action === CollectionDialogAction.Saved || + result.action === CollectionDialogAction.Deleted + ) { + this.refresh(); + + // If we deleted the selected collection, navigate up/away + if ( + result.action === CollectionDialogAction.Deleted && + this.selectedCollection?.node.id === c?.id + ) { + void this.router.navigate([], { + queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + } + } + + async bulkEditCollectionAccess( + collections: CollectionView[], + organization: Organization, + ): Promise { + if (collections.length === 0) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("noCollectionsSelected"), + }); + return; + } + + if (collections.some((c) => !c.canEdit(organization))) { + this.showMissingPermissionsError(); + return; + } + + const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { + data: { + collections, + organizationId: this.organization?.id, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkCollectionsDialogResult.Saved) { + this.refresh(); + } + } + + async bulkAssignToCollections(items: CipherView[]) { + if (items.length === 0) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + const availableCollections = await firstValueFrom(this.editableCollections$); + + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { + data: { + ciphers: items, + organizationId: this.organization?.id as OrganizationId, + availableCollections, + activeCollection: this.activeFilter?.selectedCollectionNode?.node, + isSingleCipherAdmin: + items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned), + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + this.refresh(); + } + } + + async viewEvents(cipher: CipherView) { + await openEntityEventsDialog(this.dialogService, { + data: { + name: cipher.name, + organizationId: this.organization.id, + entityId: cipher.id, + showUser: true, + entity: "cipher", + }, + }); + } + + protected deleteCipherWithServer( + id: string, + userId: UserId, + permanent: boolean, + isUnassigned: boolean, + ) { + const asAdmin = this.organization?.canEditAllCiphers || isUnassigned; + return permanent + ? this.cipherService.deleteWithServer(id, userId, asAdmin) + : this.cipherService.softDeleteWithServer(id, userId, asAdmin); + } + + protected async repromptCipher(ciphers: CipherView[]) { + const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); + + return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); + } + + private refresh() { + this.refresh$.next(); + this.vaultItemsComponent?.clearSelection(); + } + + private go(queryParams: any = null) { + if (queryParams == null) { + queryParams = { + type: this.activeFilter.cipherType, + collectionId: this.activeFilter.collectionId, + deleted: this.activeFilter.isDeleted || null, + }; + } + + void this.router.navigate([], { + relativeTo: this.route, + queryParams: queryParams, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + protected readonly CollectionDialogTabType = CollectionDialogTabType; + + private showMissingPermissionsError() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("missingPermissions"), + }); + } +} + +/** + * Allows backwards compatibility with + * old links that used the original `cipherId` param + */ +const getCipherIdFromParams = (params: Params): string => { + return params["itemId"] || params["cipherId"]; +}; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts index 960ddf4397f..d529c4c31fe 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts @@ -1,19 +1,28 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; -import { VaultComponent } from "./vault.component"; +import { VaultComponent } from "./deprecated_vault.component"; +import { vNextVaultComponent } from "./vault.component"; + const routes: Routes = [ - { - path: "", - component: VaultComponent, - canActivate: [organizationPermissionsGuard(canAccessVaultTab)], - data: { titleId: "vaults" }, - }, + ...featureFlaggedRoute({ + defaultComponent: VaultComponent, + flaggedComponent: vNextVaultComponent, + featureFlag: FeatureFlag.CollectionVaultRefactor, + routeOptions: { + data: { titleId: "vaults" }, + path: "", + canActivate: [organizationPermissionsGuard(canAccessVaultTab)], + }, + }), ]; + @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index 1122f10e8f7..44a01308bc8 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -1,156 +1,186 @@ - - - - - - - {{ freeTrial.message }} - - {{ "clickHereToAddPaymentMethod" | i18n }} - - - - - - {{ resellerWarning?.message }} - - +@let organization = organization$ | async; +@let selectedCollection = selectedCollection$ | async; +@let filter = filter$ | async; +@let refreshing = refreshingSubject$ | async; +@let loading = loading$ | async; - - -
-
- -
-
- - - {{ "all" | i18n }} - + + } - - {{ "addAccess" | i18n }} - - - - {{ trashCleanupWarning }} - - + + } + + @let freeTrial = freeTrialWhenWarningsServiceDisabled$ | async; + @if (!refreshing && freeTrial?.shownBanner) { + - - - - {{ "noItemsInList" | i18n }} - - - - - -
+ + } + + @let resellerWarning = resellerWarningWhenWarningsServiceDisabled$ | async; + @if (!refreshing && resellerWarning) { + - - {{ "loading" | i18n }} + {{ resellerWarning?.message }} + + } + + @if (filter) { + + } + +
+ @let hideVaultFilters = hideVaultFilter$ | async; + @if (!hideVaultFilters) { +
+ +
+ } + +
+ @if (showAddAccessToggle && activeFilter.selectedCollectionNode) { + + + {{ "all" | i18n }} + + + + {{ "addAccess" | i18n }} + + + } + + @if (activeFilter.isDeleted) { + + {{ trashCleanupWarning }} + + } + + @if (filter) { + + + } + + @let showCollectionAccessRestricted = showCollectionAccessRestricted$ | async; + @if (!refreshing && (isEmpty$ | async)) { + @if (!showCollectionAccessRestricted) { + + {{ "noItemsInList" | i18n }} + + @if ( + filter && + filter.type !== "trash" && + filter.collectionId !== Unassigned && + selectedCollection?.node?.canEditItems(organization) + ) { + + } + + } @else { + + + } + } + @if (refreshing) { +
+ + {{ "loading" | i18n }} +
+ }
-
+} diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 8e4d844a871..69bcd22dde1 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -1,6 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Params, Router } from "@angular/router"; import { BehaviorSubject, @@ -11,6 +10,7 @@ import { Observable, of, Subject, + zip, } from "rxjs"; import { catchError, @@ -21,7 +21,9 @@ import { first, map, shareReplay, + startWith, switchMap, + take, takeUntil, tap, } from "rxjs/operators"; @@ -52,6 +54,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -63,6 +66,10 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { BannerModule, DialogRef, @@ -163,54 +170,63 @@ enum AddAccessStatusType { { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, ], }) -export class VaultComponent implements OnInit, OnDestroy { +export class vNextVaultComponent implements OnInit, OnDestroy { protected Unassigned = Unassigned; - trashCleanupWarning: string = null; + trashCleanupWarning: string = this.i18nService.t( + this.platformUtilsService.isSelfHost() + ? "trashCleanupWarningSelfHosted" + : "trashCleanupWarning", + ); + activeFilter: VaultFilter = new VaultFilter(); protected showAddAccessToggle = false; protected noItemIcon = Search; - protected performingInitialLoad = true; - protected refreshing = false; - protected processingEvent = false; - protected filter: RoutedVaultFilterModel = {}; - protected organization: Organization; - protected allCollections: CollectionAdminView[]; - protected allGroups: GroupView[]; - protected ciphers: CipherView[]; - protected collections: CollectionAdminView[]; - protected selectedCollection: TreeNode | undefined; - protected isEmpty: boolean; - protected showCollectionAccessRestricted: boolean; + protected loading$: Observable; + protected processingEvent$ = new BehaviorSubject(false); + protected organization$: Observable; + protected allGroups$: Observable; + protected ciphers$: Observable; + protected allCiphers$: Observable; + protected showCollectionAccessRestricted$: Observable; + + protected isEmpty$: Observable = of(false); private hasSubscription$ = new BehaviorSubject(false); - protected currentSearchText$: Observable; protected useOrganizationWarningsService$: Observable; protected freeTrialWhenWarningsServiceDisabled$: Observable; protected resellerWarningWhenWarningsServiceDisabled$: Observable; protected prevCipherId: string | null = null; - protected userId: UserId; + protected userId$: Observable; + + protected hideVaultFilter$: Observable; + protected currentSearchText$: Observable; + protected filter$: Observable; + private organizationId$: Observable; + + private searchText$ = new Subject(); + protected refreshingSubject$ = new BehaviorSubject(true); + private destroy$ = new Subject(); + protected addAccessStatus$ = new BehaviorSubject(0); + private vaultItemDialogRef?: DialogRef | undefined; + /** * A list of collections that the user can assign items to and edit those items within. * @protected */ protected editableCollections$: Observable; protected allCollectionsWithoutUnassigned$: Observable; + protected allCollections$: Observable; + protected collections$: Observable; + protected selectedCollection$: Observable | undefined>; + private nestedCollections$: Observable[]>; - protected get hideVaultFilters(): boolean { - return this.organization?.isProviderUser && !this.organization?.isMember; - } - - private searchText$ = new Subject(); - private refresh$ = new BehaviorSubject(null); - private destroy$ = new Subject(); - protected addAccessStatus$ = new BehaviorSubject(0); - private vaultItemDialogRef?: DialogRef | undefined; - - @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; + @ViewChild("vaultItems", { static: false }) vaultItemsComponent: + | VaultItemsComponent + | undefined; private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), + getUserId, switchMap((id) => this.organizationService.organizations$(id).pipe( filter((organizations) => organizations.length === 1), @@ -271,56 +287,354 @@ export class VaultComponent implements OnInit, OnDestroy { private billingNotificationService: BillingNotificationService, private organizationWarningsService: OrganizationWarningsService, private collectionService: CollectionService, - ) {} + ) { + this.userId$ = this.accountService.activeAccount$.pipe(getUserId); + this.filter$ = this.routedVaultFilterService.filter$; + this.organizationId$ = + // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault, + // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here, + // but really we should change to using our own vault filter model that only represents valid states in AC. + this.filter$.pipe( + map((filter) => filter.organizationId), + filter((filter) => filter !== undefined), + filter( + (value: OrganizationId | Unassigned): value is OrganizationId => value !== Unassigned, + ), + distinctUntilChanged(), + ); - async ngOnInit() { - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); - this.trashCleanupWarning = this.i18nService.t( - this.platformUtilsService.isSelfHost() - ? "trashCleanupWarningSelfHosted" - : "trashCleanupWarning", + this.organization$ = combineLatest([this.organizationId$, this.userId$]).pipe( + switchMap(([orgId, userId]) => + this.organizationService.organizations$(userId).pipe(getById(orgId)), + ), + filter((organization) => organization != null), + shareReplay({ refCount: true, bufferSize: 1 }), ); - const filter$ = this.routedVaultFilterService.filter$; - - // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault, - // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here, - // but really we should change to using our own vault filter model that only represents valid states in AC. - const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId => - value !== Unassigned; - const organizationId$ = filter$.pipe( - map((filter) => filter.organizationId), - filter((filter) => filter !== undefined), - filter(isOrganizationId), - distinctUntilChanged(), + this.hideVaultFilter$ = this.organization$.pipe( + map((organization) => organization.isProviderUser && !organization.isMember), ); - const organization$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), - switchMap((id) => - organizationId$.pipe( - switchMap((organizationId) => - this.organizationService - .organizations$(id) - .pipe(map((organizations) => organizations.find((org) => org.id === organizationId))), + this.allCollectionsWithoutUnassigned$ = this.refreshingSubject$.pipe( + filter((refreshing) => refreshing), + switchMap(() => combineLatest([this.organizationId$, this.userId$])), + switchMap(([orgId, userId]) => + this.collectionAdminService.collectionAdminViews$(orgId, userId), + ), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.allCollections$ = combineLatest([ + this.organizationId$, + this.allCollectionsWithoutUnassigned$, + ]).pipe( + map(([organizationId, allCollections]) => { + // FIXME: We should not assert that the Unassigned type is a CollectionId. + // Instead we should consider representing the Unassigned collection as a different object, given that + // it is not actually a collection. + const noneCollection = new CollectionAdminView({ + name: this.i18nService.t("unassigned"), + id: Unassigned as CollectionId, + organizationId: organizationId, + }); + return allCollections.concat(noneCollection); + }), + ); + + this.nestedCollections$ = this.allCollections$.pipe( + map((collections) => getNestedCollectionTree(collections)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.allGroups$ = this.organizationId$.pipe( + switchMap((organizationId) => this.groupService.getAll(organizationId)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.allCiphers$ = combineLatest([ + this.organization$, + this.userId$, + this.refreshingSubject$, + ]).pipe( + switchMap(async ([organization, userId]) => { + // If user swaps organization reset the addAccessToggle + if (!this.showAddAccessToggle || organization) { + this.addAccessToggle(0); + } + let ciphers; + + // Restricted providers (who are not members) do not have access org cipher endpoint below + // Return early to avoid 404 response + if (!organization.isMember && organization.isProviderUser) { + return []; + } + + // If the user can edit all ciphers for the organization then fetch them ALL. + if (organization.canEditAllCiphers) { + ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + ciphers.forEach((c) => (c.edit = true)); + } else { + // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). + ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); + } + + await this.searchService.indexCiphers(userId, ciphers, organization.id); + return ciphers; + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.selectedCollection$ = combineLatest([this.nestedCollections$, this.filter$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter]) => { + if ( + filter.collectionId === undefined || + filter.collectionId === All || + filter.collectionId === Unassigned + ) { + return; + } + + return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.showCollectionAccessRestricted$ = combineLatest([ + this.filter$, + this.selectedCollection$, + this.organization$, + ]).pipe( + map(([filter, collection, organization]) => { + return ( + (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || + (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) + ); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.ciphers$ = combineLatest([ + this.allCiphers$, + this.filter$, + this.currentSearchText$, + this.showCollectionAccessRestricted$, + this.userId$, + ]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted, userId]) => { + if (filter.collectionId === undefined && filter.type === undefined) { + return []; + } + + if (showCollectionAccessRestricted) { + // Do not show ciphers for restricted collections + // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible + return []; + } + + const filterFunction = createFilterFunction(filter); + + if (await this.searchService.isSearchable(userId, searchText)) { + return await this.searchService.searchCiphers( + userId, + searchText, + [filterFunction], + ciphers, + ); + } + + return ciphers.filter(filterFunction); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + // Billing Warnings + this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$( + FeatureFlag.UseOrganizationWarningsService, + ); + + const freeTrial$ = combineLatest([ + this.organization$, + this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), + ]).pipe( + filter( + ([org, hasSubscription]) => org.isOwner && hasSubscription && org.canViewBillingHistory, + ), + switchMap(([org]) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + from(this.organizationBillingService.getPaymentSource(org.id)).pipe( + map((paymentSource) => { + if (paymentSource == null) { + throw new Error("Payment source not found."); + } + return paymentSource; + }), ), - takeUntil(this.destroy$), - shareReplay({ refCount: false, bufferSize: 1 }), + ]), + ), + map(([org, sub, paymentSource]) => + this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource), + ), + filter((result) => result !== null), + catchError((error: unknown) => { + this.billingNotificationService.handleError(error); + return of(); + }), + ); + + this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( + filter((enabled) => !enabled), + switchMap(() => freeTrial$), + ); + + this.resellerWarningWhenWarningsServiceDisabled$ = combineLatest([ + this.organization$, + this.useOrganizationWarningsService$, + ]).pipe( + filter(([org, enabled]) => !enabled && org.isOwner), + switchMap(([org]) => + from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe( + map((metadata) => ({ org, metadata })), ), ), + map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)), ); - const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe( + this.organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntilDestroyed(), + ) + .subscribe(); + + // End Billing Warnings + + this.editableCollections$ = combineLatest([ + this.allCollectionsWithoutUnassigned$, + this.organization$, + ]).pipe( + map(([collections, organization]) => { + // Users that can edit all ciphers can implicitly add to / edit within any collection + if (organization.canEditAllCiphers) { + return collections; + } + return collections.filter((c) => c.assigned); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.collections$ = combineLatest([ + this.nestedCollections$, + this.filter$, + this.currentSearchText$, + this.addAccessStatus$, + this.userId$, + this.organization$, + ]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + concatMap( + async ([collections, filter, searchText, addAccessStatus, userId, organization]) => { + if ( + filter.collectionId === Unassigned || + (filter.collectionId === undefined && filter.type !== undefined) + ) { + return []; + } + + this.showAddAccessToggle = false; + let searchableCollectionNodes: TreeNode[] = []; + if (filter.collectionId === undefined || filter.collectionId === All) { + searchableCollectionNodes = collections; + } else { + const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( + collections, + filter.collectionId, + ); + searchableCollectionNodes = selectedCollection.children ?? []; + } + + let collectionsToReturn: CollectionAdminView[] = []; + + if (await this.searchService.isSearchable(userId, searchText)) { + // Flatten the tree for searching through all levels + const flatCollectionTree: CollectionAdminView[] = + getFlatCollectionTree(searchableCollectionNodes); + + collectionsToReturn = this.searchPipe.transform( + flatCollectionTree, + searchText, + (collection) => collection.name, + (collection) => collection.id, + ); + } else { + collectionsToReturn = searchableCollectionNodes.map( + (treeNode: TreeNode): CollectionAdminView => treeNode.node, + ); + } + + // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit + this.showAddAccessToggle = + !organization.allowAdminAccessToAllCollectionItems && + organization.canEditUnmanagedCollections && + collectionsToReturn.some((c) => c.unmanaged); + + if (addAccessStatus === 1 && this.showAddAccessToggle) { + collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); + } + return collectionsToReturn; + }, + ), + takeUntil(this.destroy$), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const firstLoadComplete$ = zip([ + this.organization$, + this.filter$, + this.allCollections$, + this.allGroups$, + this.ciphers$, + this.collections$, + this.selectedCollection$, + this.showCollectionAccessRestricted$, + ]).pipe( + map(() => true), + startWith(false), + take(2), // Only take the emmision from startsWith and the emission from zip. + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.loading$ = combineLatest([ + this.refreshingSubject$, + this.processingEvent$, + firstLoadComplete$, + ]).pipe( + map( + ([refreshing, processing, firstLoadComplete]) => + refreshing || processing || !firstLoadComplete, + ), + ); + } + + async ngOnInit() { + const firstSetup$ = combineLatest([this.organization$, this.route.queryParams]).pipe( first(), switchMap(async ([organization]) => { - this.organization = organization; - if (!organization.canEditAnyCollection) { await this.syncService.fullSync(false); } - - return undefined; + return; + }), + catchError((error: unknown) => { + this.logService.error("Failed during firstSetup$:", error); + return of(); }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -361,218 +675,13 @@ export class VaultComponent implements OnInit, OnDestroy { }), ); - this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); - - this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( - switchMap(() => organizationId$), - switchMap((orgId) => - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), - ), - ), - shareReplay({ refCount: false, bufferSize: 1 }), - ); - - this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( - map((collections) => { - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (this.organization.canEditAllCiphers) { - return collections; - } - return collections.filter((c) => c.assigned); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCollections$ = combineLatest([ - organizationId$, - this.allCollectionsWithoutUnassigned$, - ]).pipe( - map(([organizationId, allCollections]) => { - // FIXME: We should not assert that the Unassigned type is a CollectionId. - // Instead we should consider representing the Unassigned collection as a different object, given that - // it is not actually a collection. - return allCollections.concat( - new CollectionAdminView({ - name: this.i18nService.t("unassigned"), - id: Unassigned as CollectionId, - organizationId, - }), - ); - }), - ); - - const allGroups$ = organizationId$.pipe( - switchMap((organizationId) => this.groupService.getAll(organizationId)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe( - switchMap(async ([organization]) => { - // If user swaps organization reset the addAccessToggle - if (!this.showAddAccessToggle || organization) { - this.addAccessToggle(0); - } - let ciphers; - - // Restricted providers (who are not members) do not have access org cipher endpoint below - // Return early to avoid 404 response - if (!organization.isMember && organization.isProviderUser) { - return []; - } - - // If the user can edit all ciphers for the organization then fetch them ALL. - if (organization.canEditAllCiphers) { - ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); - ciphers?.forEach((c) => (c.edit = true)); - } else { - // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). - ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); - } - - await this.searchService.indexCiphers(this.userId, ciphers, organization.id); - return ciphers; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCipherMap$ = allCiphers$.pipe( + const allCipherMap$ = this.allCiphers$.pipe( map((ciphers) => { return Object.fromEntries(ciphers.map((c) => [c.id, c])); }), ); - const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const collections$ = combineLatest([ - nestedCollections$, - filter$, - this.currentSearchText$, - this.addAccessStatus$, - ]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - concatMap(async ([collections, filter, searchText, addAccessStatus]) => { - if ( - filter.collectionId === Unassigned || - (filter.collectionId === undefined && filter.type !== undefined) - ) { - return []; - } - - this.showAddAccessToggle = false; - let searchableCollectionNodes: TreeNode[] = []; - if (filter.collectionId === undefined || filter.collectionId === All) { - searchableCollectionNodes = collections; - } else { - const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( - collections, - filter.collectionId, - ); - searchableCollectionNodes = selectedCollection?.children ?? []; - } - - let collectionsToReturn: CollectionAdminView[] = []; - - if (await this.searchService.isSearchable(this.userId, searchText)) { - // Flatten the tree for searching through all levels - const flatCollectionTree: CollectionAdminView[] = - getFlatCollectionTree(searchableCollectionNodes); - - collectionsToReturn = this.searchPipe.transform( - flatCollectionTree, - searchText, - (collection) => collection.name, - (collection) => collection.id, - ); - } else { - collectionsToReturn = searchableCollectionNodes.map( - (treeNode: TreeNode): CollectionAdminView => treeNode.node, - ); - } - - // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit - this.showAddAccessToggle = - !this.organization.allowAdminAccessToAllCollectionItems && - this.organization.canEditUnmanagedCollections && - collectionsToReturn.some((c) => c.unmanaged); - - if (addAccessStatus === 1 && this.showAddAccessToggle) { - collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); - } - return collectionsToReturn; - }), - takeUntil(this.destroy$), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter]) => { - if ( - filter.collectionId === undefined || - filter.collectionId === All || - filter.collectionId === Unassigned - ) { - return undefined; - } - - return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const showCollectionAccessRestricted$ = combineLatest([ - filter$, - selectedCollection$, - organization$, - ]).pipe( - map(([filter, collection, organization]) => { - return ( - (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || - (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) - ); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const ciphers$ = combineLatest([ - allCiphers$, - filter$, - this.currentSearchText$, - showCollectionAccessRestricted$, - ]).pipe( - filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), - concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => { - if (filter.collectionId === undefined && filter.type === undefined) { - return []; - } - - if (showCollectionAccessRestricted) { - // Do not show ciphers for restricted collections - // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible - return []; - } - - const filterFunction = createFilterFunction(filter); - - if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( - this.userId, - searchText, - [filterFunction], - ciphers, - ); - } - - return ciphers.filter(filterFunction); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - + // Handle deep linking to a specific cipher (if the route specifies a cipherId) firstSetup$ .pipe( switchMap(() => combineLatest([this.route.queryParams, allCipherMap$])), @@ -620,7 +729,7 @@ export class VaultComponent implements OnInit, OnDestroy { } else { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("unknownCipher"), }); await this.router.navigate([], { @@ -633,9 +742,12 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + // Handle deep linking to a cipher event firstSetup$ .pipe( - switchMap(() => combineLatest([this.route.queryParams, organization$, allCiphers$])), + switchMap(() => + combineLatest([this.route.queryParams, this.organization$, this.allCiphers$]), + ), switchMap(async ([qParams, organization, allCiphers$]) => { const cipherId = qParams.viewEvents; if (!cipherId) { @@ -647,7 +759,7 @@ export class VaultComponent implements OnInit, OnDestroy { } else { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("unknownCipher"), }); await this.router.navigate([], { @@ -660,125 +772,38 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); - // Billing Warnings - this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$( - FeatureFlag.UseOrganizationWarningsService, - ); - - this.useOrganizationWarningsService$ + combineLatest([this.useOrganizationWarningsService$, this.organization$]) .pipe( - switchMap((enabled) => + switchMap(([enabled, organization]) => enabled - ? this.organizationWarningsService.showInactiveSubscriptionDialog$(this.organization) + ? this.organizationWarningsService.showInactiveSubscriptionDialog$(organization) : this.unpaidSubscriptionDialog$, ), takeUntil(this.destroy$), ) .subscribe(); - organization$ - .pipe( - switchMap((organization) => - this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), - ), - takeUntil(this.destroy$), - ) - .subscribe(); - - const freeTrial$ = combineLatest([ - organization$, - this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), - ]).pipe( - filter( - ([org, hasSubscription]) => org.isOwner && hasSubscription && org.canViewBillingHistory, - ), - switchMap(([org]) => - combineLatest([ - of(org), - this.organizationApiService.getSubscription(org.id), - from(this.organizationBillingService.getPaymentSource(org.id)).pipe( - catchError((error: unknown) => { - this.billingNotificationService.handleError(error); - return of(null); - }), - ), - ]), - ), - map(([org, sub, paymentSource]) => - this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource), - ), - filter((result) => result !== null), - ); - - this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( - filter((enabled) => !enabled), - switchMap(() => freeTrial$), - ); - - const resellerWarning$ = organization$.pipe( - filter((org) => org.isOwner), - switchMap((org) => - from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe( - map((metadata) => ({ org, metadata })), - ), - ), - map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)), - ); - - this.resellerWarningWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe( - filter((enabled) => !enabled), - switchMap(() => resellerWarning$), - ); - // End Billing Warnings - + // Handle last of initial setup - workaround for some state issues where we need to manually + // push the collections we've loaded back into the VaultFilterService. + // FIXME: figure out how we can remove this. firstSetup$ .pipe( - switchMap(() => this.refresh$), - tap(() => (this.refreshing = true)), - switchMap(() => - combineLatest([ - organization$, - filter$, - allCollections$, - allGroups$, - ciphers$, - collections$, - selectedCollection$, - showCollectionAccessRestricted$, - ]), - ), + switchMap(() => this.allCollections$), takeUntil(this.destroy$), ) - .subscribe( - ([ - organization, - filter, - allCollections, - allGroups, - ciphers, - collections, - selectedCollection, - showCollectionAccessRestricted, - ]) => { - this.organization = organization; - this.filter = filter; - this.allCollections = allCollections; - this.allGroups = allGroups; - this.ciphers = ciphers; - this.collections = collections; - this.selectedCollection = selectedCollection; - this.showCollectionAccessRestricted = showCollectionAccessRestricted; - - this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - - // This is a temporary fix to avoid double fetching collections. - // TODO: Remove when implementing new VVR menu + .subscribe((allCollections) => { + // This is a temporary fix to avoid double fetching collections. + // TODO: Remove when implementing new VVR menu + if (this.vaultFilterService.reloadCollections) { this.vaultFilterService.reloadCollections(allCollections); + } - this.refreshing = false; - this.performingInitialLoad = false; - }, - ); + this.refreshingSubject$.next(false); + }); + + this.isEmpty$ = combineLatest([this.ciphers$, this.collections$]).pipe( + map(([ciphers, collections]) => collections.length === 0 && ciphers?.length === 0), + ); } async navigateToPaymentMethod() { @@ -786,7 +811,8 @@ export class VaultComponent implements OnInit, OnDestroy { FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, ); const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { + const organizationId = await firstValueFrom(this.organizationId$); + await this.router.navigate(["organizations", `${organizationId}`, "billing", route], { state: { launchPaymentModalAutomatically: true }, }); } @@ -795,10 +821,6 @@ export class VaultComponent implements OnInit, OnDestroy { this.addAccessStatus$.next(e); } - get loading() { - return this.refreshing || this.processingEvent; - } - ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.destroy$.next(); @@ -806,9 +828,10 @@ export class VaultComponent implements OnInit, OnDestroy { } async onVaultItemsEvent(event: VaultItemEvent) { - this.processingEvent = true; + this.processingEvent$.next(true); try { + const organization = await firstValueFrom(this.organization$); switch (event.type) { case "viewAttachments": await this.editCipherAttachments(event.item); @@ -826,16 +849,18 @@ export class VaultComponent implements OnInit, OnDestroy { case "delete": { const ciphers = event.items .filter((i) => i.collection === undefined) - .map((i) => i.cipher); + .map((i) => i.cipher) + .filter((c) => c != null); const collections = event.items .filter((i) => i.cipher === undefined) - .map((i) => i.collection); + .map((i) => i.collection) + .filter((c) => c != null); if (ciphers.length === 1 && collections.length === 0) { await this.deleteCipher(ciphers[0]); } else if (ciphers.length === 0 && collections.length === 1) { await this.deleteCollection(collections[0] as CollectionAdminView); } else { - await this.bulkDelete(ciphers, collections, this.organization); + await this.bulkDelete(ciphers, collections, organization); } break; } @@ -857,7 +882,7 @@ export class VaultComponent implements OnInit, OnDestroy { ); break; case "bulkEditCollectionAccess": - await this.bulkEditCollectionAccess(event.items, this.organization); + await this.bulkEditCollectionAccess(event.items, organization); break; case "assignToCollections": await this.bulkAssignToCollections(event.items); @@ -867,7 +892,7 @@ export class VaultComponent implements OnInit, OnDestroy { break; } } finally { - this.processingEvent = false; + this.processingEvent$.next(false); } } @@ -876,12 +901,13 @@ export class VaultComponent implements OnInit, OnDestroy { } async editCipherAttachments(cipher: CipherView) { - if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { + if (cipher.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { this.go({ cipherId: null, itemId: null }); return; } - if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { + const organization = await firstValueFrom(this.organization$); + if (organization.maxStorageGb == null || organization.maxStorageGb === 0) { this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); return; } @@ -895,8 +921,8 @@ export class VaultComponent implements OnInit, OnDestroy { const result = await firstValueFrom(dialogRef.closed); if ( - result.action === AttachmentDialogResult.Removed || - result.action === AttachmentDialogResult.Uploaded + result?.action === AttachmentDialogResult.Removed || + result?.action === AttachmentDialogResult.Uploaded ) { this.refresh(); } @@ -906,14 +932,15 @@ export class VaultComponent implements OnInit, OnDestroy { async addCipher(cipherType?: CipherType) { const cipherFormConfig = await this.cipherFormConfigService.buildConfig( "add", - null, + undefined, cipherType, ); const collectionId: CollectionId | undefined = this.activeFilter.collectionId as CollectionId; + const organization = await firstValueFrom(this.organization$); cipherFormConfig.initialValues = { - organizationId: this.organization.id as OrganizationId, + organizationId: organization.id, collectionIds: collectionId ? [collectionId] : [], }; @@ -925,7 +952,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipherView - When set, the cipher to be edited * @param cloneCipher - `true` when the cipher should be cloned. */ - async editCipher(cipher: CipherView | null, cloneCipher: boolean) { + async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) { if ( cipher && cipher.reprompt !== 0 && @@ -938,7 +965,7 @@ export class VaultComponent implements OnInit, OnDestroy { const cipherFormConfig = await this.cipherFormConfigService.buildConfig( cloneCipher ? "clone" : "edit", - cipher?.id as CipherId | null, + cipher?.id as CipherId | undefined, ); await this.openVaultItemDialog("form", cipherFormConfig, cipher); @@ -983,7 +1010,8 @@ export class VaultComponent implements OnInit, OnDestroy { cipher?: CipherView, activeCollectionId?: CollectionId, ) { - const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; + const organization = await firstValueFrom(this.organization$); + const disableForm = cipher ? !cipher.edit && !organization.canEditAllCiphers : false; // If the form is disabled, force the mode into `view` const dialogMode = disableForm ? "view" : mode; this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { @@ -1008,7 +1036,7 @@ export class VaultComponent implements OnInit, OnDestroy { } async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { + if (cipher.login.hasFido2Credentials) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -1023,44 +1051,52 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCipher(cipher, true); } - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { - return; + restore = async (c: CipherViewLike): Promise => { + const organization = await firstValueFrom(this.organization$); + if (!CipherViewLikeUtils.isDeleted(c)) { + return false; } if ( - !this.organization.permissions.editAnyCollection && + !organization.permissions.editAnyCollection && !c.edit && - !this.organization.allowAdminAccessToAllCollectionItems + !organization.allowAdminAccessToAllCollectionItems ) { this.showMissingPermissionsError(); - return; + return false; } if (!(await this.repromptCipher([c]))) { - return; + return false; } // Allow restore of an Unassigned Item try { + if (c.id == null) { + throw new Error("Cipher must have an Id to be restored"); + } const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const asAdmin = this.organization?.canEditAnyCollection || c.isUnassigned; - await this.cipherService.restoreWithServer(c.id, activeUserId, asAdmin); + const organization = await firstValueFrom(this.organization$); + const asAdmin = organization.canEditAnyCollection || CipherViewLikeUtils.isUnassigned(c); + await this.cipherService.restoreWithServer(c.id as CipherId, activeUserId, asAdmin); this.toastService.showToast({ variant: "success", - title: null, + message: this.i18nService.t("restoredItem"), }); this.refresh(); + return true; } catch (e) { this.logService.error(e); + return false; } }; async bulkRestore(ciphers: CipherView[]) { + const organization = await firstValueFrom(this.organization$); if ( - !this.organization.permissions.editAnyCollection && - ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems) + !organization.permissions.editAnyCollection && + ciphers.some((c) => !c.edit && !organization.allowAdminAccessToAllCollectionItems) ) { this.showMissingPermissionsError(); return; @@ -1074,8 +1110,9 @@ export class VaultComponent implements OnInit, OnDestroy { const editAccessCiphers: string[] = []; const unassignedCiphers: string[] = []; + const userId = await firstValueFrom(this.userId$); // If user has edit all Access no need to check for unassigned ciphers - if (this.organization.canEditAllCiphers) { + if (organization.canEditAllCiphers) { ciphers.map((cipher) => { editAccessCiphers.push(cipher.id); }); @@ -1101,27 +1138,28 @@ export class VaultComponent implements OnInit, OnDestroy { if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) { await this.cipherService.restoreManyWithServer( [...unassignedCiphers, ...editAccessCiphers], - this.userId, - this.organization.id, + userId, + organization.id, ); } this.toastService.showToast({ variant: "success", - title: null, + message: this.i18nService.t("restoredItems"), }); this.refresh(); } async deleteCipher(c: CipherView): Promise { - if (!c.edit && !this.organization.canEditAllCiphers) { + const organization = await firstValueFrom(this.organization$); + if (!c.edit && !organization.canEditAllCiphers) { this.showMissingPermissionsError(); - return; + return false; } if (!(await this.repromptCipher([c]))) { - return; + return false; } const permanent = c.isDeleted; @@ -1141,17 +1179,21 @@ export class VaultComponent implements OnInit, OnDestroy { await this.deleteCipherWithServer(c.id, activeUserId, permanent, c.isUnassigned); this.toastService.showToast({ variant: "success", - title: null, + message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"), }); this.refresh(); + return true; } catch (e) { this.logService.error(e); + return false; } } async deleteCollection(collection: CollectionAdminView): Promise { - if (!collection.canDelete(this.organization)) { + const organization = await firstValueFrom(this.organization$); + const userId = await firstValueFrom(this.userId$); + if (!collection.canDelete(organization)) { this.showMissingPermissionsError(); return; } @@ -1165,11 +1207,11 @@ export class VaultComponent implements OnInit, OnDestroy { return; } try { - await this.apiService.deleteCollection(this.organization?.id, collection.id); - await this.collectionService.delete([collection.id as CollectionId], this.userId); + await this.apiService.deleteCollection(organization.id, collection.id); + await this.collectionService.delete([collection.id], userId); this.toastService.showToast({ variant: "success", - title: null, + message: this.i18nService.t("deletedCollectionId", collection.name), }); @@ -1177,9 +1219,10 @@ export class VaultComponent implements OnInit, OnDestroy { await this.cipherService.clear(); // Navigate away if we deleted the collection we were viewing - if (this.selectedCollection?.node.id === collection.id) { + const selectedCollection = await firstValueFrom(this.selectedCollection$); + if (selectedCollection?.node.id === collection.id) { void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParams: { collectionId: selectedCollection.parent.node.id ?? null }, queryParamsHandling: "merge", replaceUrl: true, }); @@ -1215,25 +1258,27 @@ export class VaultComponent implements OnInit, OnDestroy { if (ciphers.length === 0 && collections.length === 0) { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("nothingSelected"), }); return; } + const org = await firstValueFrom(this.organization$); const canDeleteCollections = collections == null || collections.every((c) => c.canDelete(organization)); const canDeleteCiphers = - ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers; + ciphers == null || ciphers.every((c) => c.edit) || org.canEditAllCiphers; if (!canDeleteCiphers || !canDeleteCollections) { this.showMissingPermissionsError(); return; } + const filter = await firstValueFrom(this.filter$); const dialog = openBulkDeleteDialog(this.dialogService, { data: { - permanent: this.filter.type === "trash", + permanent: filter.type === "trash", cipherIds: assignedCiphers, collections: collections, organization, @@ -1263,12 +1308,12 @@ export class VaultComponent implements OnInit, OnDestroy { } else if (field === "totp") { aType = "TOTP"; const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); - value = totpResponse?.code; + value = totpResponse.code; typeI18nKey = "verificationCodeTotp"; } else { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("unexpectedError"), }); return; @@ -1288,7 +1333,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.platformUtilsService.copyToClipboard(value, { window: window }); this.toastService.showToast({ variant: "info", - title: null, + message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)), }); @@ -1303,19 +1348,21 @@ export class VaultComponent implements OnInit, OnDestroy { } async addCollection(): Promise { + const organization = await firstValueFrom(this.organization$); + const selectedCollection = await firstValueFrom(this.selectedCollection$); const dialog = openCollectionDialog(this.dialogService, { data: { - organizationId: this.organization?.id, - parentCollectionId: this.selectedCollection?.node.id, - limitNestedCollections: !this.organization.canEditAnyCollection, + organizationId: organization.id, + parentCollectionId: selectedCollection?.node.id, + limitNestedCollections: !organization.canEditAnyCollection, isAdminConsoleActive: true, }, }); const result = await lastValueFrom(dialog.closed); if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted + result?.action === CollectionDialogAction.Saved || + result?.action === CollectionDialogAction.Deleted ) { this.refresh(); } @@ -1326,32 +1373,34 @@ export class VaultComponent implements OnInit, OnDestroy { tab: CollectionDialogTabType, readonly: boolean, ): Promise { + const organization = await firstValueFrom(this.organization$); const dialog = openCollectionDialog(this.dialogService, { data: { - collectionId: c?.id, - organizationId: this.organization?.id, + collectionId: c.id, + organizationId: organization.id, initialTab: tab, readonly: readonly, isAddAccessCollection: c.unmanaged, - limitNestedCollections: !this.organization.canEditAnyCollection, + limitNestedCollections: !organization.canEditAnyCollection, isAdminConsoleActive: true, }, }); const result = await lastValueFrom(dialog.closed); if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted + result?.action === CollectionDialogAction.Saved || + result?.action === CollectionDialogAction.Deleted ) { this.refresh(); + const selectedCollection = await firstValueFrom(this.selectedCollection$); // If we deleted the selected collection, navigate up/away if ( result.action === CollectionDialogAction.Deleted && - this.selectedCollection?.node.id === c?.id + selectedCollection?.node.id === c.id ) { void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParams: { collectionId: selectedCollection.parent.node.id ?? null }, queryParamsHandling: "merge", replaceUrl: true, }); @@ -1366,7 +1415,7 @@ export class VaultComponent implements OnInit, OnDestroy { if (collections.length === 0) { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("noCollectionsSelected"), }); return; @@ -1377,10 +1426,11 @@ export class VaultComponent implements OnInit, OnDestroy { return; } + const org = await firstValueFrom(this.organization$); const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { data: { collections, - organizationId: this.organization?.id, + organizationId: org.id, }, }); @@ -1394,7 +1444,7 @@ export class VaultComponent implements OnInit, OnDestroy { if (items.length === 0) { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("nothingSelected"), }); return; @@ -1402,14 +1452,15 @@ export class VaultComponent implements OnInit, OnDestroy { const availableCollections = await firstValueFrom(this.editableCollections$); + const organization = await firstValueFrom(this.organization$); const dialog = AssignCollectionsWebComponent.open(this.dialogService, { data: { ciphers: items, - organizationId: this.organization?.id as OrganizationId, + organizationId: organization.id, availableCollections, - activeCollection: this.activeFilter?.selectedCollectionNode?.node, + activeCollection: this.activeFilter.selectedCollectionNode.node, isSingleCipherAdmin: - items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned), + items.length === 1 && (organization.canEditAllCiphers || items[0].isUnassigned), }, }); @@ -1420,10 +1471,11 @@ export class VaultComponent implements OnInit, OnDestroy { } async viewEvents(cipher: CipherView) { - await openEntityEventsDialog(this.dialogService, { + const organization = await firstValueFrom(this.organization$); + openEntityEventsDialog(this.dialogService, { data: { name: cipher.name, - organizationId: this.organization.id, + organizationId: organization.id, entityId: cipher.id, showUser: true, entity: "cipher", @@ -1431,27 +1483,30 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - protected deleteCipherWithServer( + protected async deleteCipherWithServer( id: string, userId: UserId, permanent: boolean, isUnassigned: boolean, ) { - const asAdmin = this.organization?.canEditAllCiphers || isUnassigned; + const organization = await firstValueFrom(this.organization$); + const asAdmin = organization.canEditAllCiphers || isUnassigned; return permanent ? this.cipherService.deleteWithServer(id, userId, asAdmin) : this.cipherService.softDeleteWithServer(id, userId, asAdmin); } - protected async repromptCipher(ciphers: CipherView[]) { + protected async repromptCipher(ciphers: CipherViewLike[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); } private refresh() { - this.refresh$.next(); - this.vaultItemsComponent?.clearSelection(); + this.refreshingSubject$.next(true); + if (this.vaultItemsComponent) { + this.vaultItemsComponent.clearSelection(); + } } private go(queryParams: any = null) { @@ -1476,7 +1531,7 @@ export class VaultComponent implements OnInit, OnDestroy { private showMissingPermissionsError() { this.toastService.showToast({ variant: "error", - title: null, + message: this.i18nService.t("missingPermissions"), }); } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts index 1a093ff8352..92dbc5d832c 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts @@ -6,9 +6,10 @@ import { ViewComponent } from "../../../vault/individual-vault/view.component"; import { CollectionDialogComponent } from "../shared/components/collection-dialog"; import { CollectionNameBadgeComponent } from "./collection-badge"; +import { VaultComponent } from "./deprecated_vault.component"; import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { VaultRoutingModule } from "./vault-routing.module"; -import { VaultComponent } from "./vault.component"; +import { vNextVaultComponent } from "./vault.component"; @NgModule({ imports: [ @@ -19,6 +20,7 @@ import { VaultComponent } from "./vault.component"; OrganizationBadgeModule, CollectionDialogComponent, VaultComponent, + vNextVaultComponent, ViewComponent, ], }) diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 770cfd0011d..58d6d9efef9 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -51,6 +51,9 @@ export function canAccessOrgAdmin(org: Organization): boolean { ); } +/** + * @deprecated Please use the general `getById` custom rxjs operator instead. + */ export function getOrganizationById(id: string) { return map((orgs) => orgs.find((o) => o.id === id)); } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 30644b95627..b339798f914 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ CreateDefaultLocation = "pm-19467-create-default-location", + CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors", /* Auth */ PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", @@ -71,6 +72,7 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.CreateDefaultLocation]: FALSE, + [FeatureFlag.CollectionVaultRefactor]: FALSE, /* Autofill */ [FeatureFlag.NotificationRefresh]: FALSE, diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 5ef1d9bdc75..5cb4a7a084e 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -80,6 +80,18 @@ export class CipherViewLikeUtils { return cipher.isDeleted; }; + /** @returns `true` when the cipher is not assigned to a collection, `false` otherwise. */ + static isUnassigned = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return ( + cipher.organizationId != null && + (cipher.collectionIds == null || cipher.collectionIds.length === 0) + ); + } + + return cipher.isUnassigned; + }; + /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */ static canAssignToCollections = (cipher: CipherViewLike): boolean => { if (this.isCipherListView(cipher)) { From ca554897be62be48ce0b0a80dead40948d3a1ad5 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:39:34 -0500 Subject: [PATCH 22/25] [PM-24269] Enable ownership field for personal items (#16069) * remove global check for personal ownership as `setFormState` now handles it * ensure that the organizationId is disabled for new ciphers * only check for personal ownership change for enabling/disabling the entire form - this ensure that it is only applied when the data ownership policy is applied - The bug was caused by a regular user that wasn't in an organization, their form was getting fully disabled when it shouldn't. * fix type checking * do not disable organization id after an organization is selected --- .../item-details-section.component.spec.ts | 93 ++++++++++++++++++- .../item-details-section.component.ts | 19 ++-- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index e3d863a0af3..c41e58f679e 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -15,6 +15,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SelectComponent } from "@bitwarden/components"; @@ -62,16 +63,22 @@ describe("ItemDetailsSectionComponent", () => { let mockPolicyService: MockProxy; const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" }); - const getInitialCipherView = jest.fn(() => null); + const getInitialCipherView = jest.fn(() => null); const initializedWithCachedCipher = jest.fn(() => false); + const disableFormFields = jest.fn(); + const enableFormFields = jest.fn(); beforeEach(async () => { getInitialCipherView.mockClear(); initializedWithCachedCipher.mockClear(); + disableFormFields.mockClear(); + enableFormFields.mockClear(); cipherFormProvider = mock({ getInitialCipherView, initializedWithCachedCipher, + disableFormFields, + enableFormFields, }); i18nService = mock(); i18nService.collator = { @@ -151,7 +158,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1"], favorite: true, - }); + } as CipherView); await component.ngOnInit(); tick(); @@ -420,7 +427,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1", "col2"], favorite: true, - }); + } as CipherView); component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ createMockCollection("col1", "Collection 1", "org1") as CollectionView, @@ -467,7 +474,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1", "col2", "col3"], favorite: true, - }); + } as CipherView); component.originalCipherView = { name: "cipher1", organizationId: "org1", @@ -513,6 +520,7 @@ describe("ItemDetailsSectionComponent", () => { expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); }); }); + describe("readonlyCollections", () => { beforeEach(() => { component.config.mode = "edit"; @@ -594,4 +602,81 @@ describe("ItemDetailsSectionComponent", () => { expect(result).toBeUndefined(); }); }); + + describe("form status when editing a cipher", () => { + beforeEach(() => { + component.config.mode = "edit"; + component.config.originalCipher = new Cipher(); + component.originalCipherView = { + name: "cipher1", + organizationId: null, + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as unknown as CipherView; + }); + + describe("when personal ownership is not allowed", () => { + beforeEach(() => { + component.config.organizationDataOwnershipDisabled = false; // disallow personal ownership + component.config.organizations = [{ id: "orgId" } as Organization]; + }); + + describe("cipher does not belong to an organization", () => { + beforeEach(() => { + getInitialCipherView.mockReturnValue(component.originalCipherView!); + }); + + it("enables organizationId", async () => { + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(false); + }); + + it("disables the rest of the form", async () => { + await component.ngOnInit(); + + expect(disableFormFields).toHaveBeenCalled(); + expect(enableFormFields).not.toHaveBeenCalled(); + }); + }); + + describe("cipher belongs to an organization", () => { + beforeEach(() => { + component.originalCipherView.organizationId = "org-id"; + getInitialCipherView.mockReturnValue(component.originalCipherView); + }); + + it("enables the rest of the form", async () => { + await component.ngOnInit(); + + expect(disableFormFields).not.toHaveBeenCalled(); + expect(enableFormFields).toHaveBeenCalled(); + }); + }); + }); + + describe("when an ownership change is not allowed", () => { + beforeEach(() => { + component.config.organizationDataOwnershipDisabled = true; // allow personal ownership + component.originalCipherView!.organizationId = undefined; + }); + + it("disables organizationId when the cipher is owned by an organization", async () => { + component.originalCipherView!.organizationId = "orgId"; + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true); + }); + + it("disables organizationId when personal ownership is allowed and the user has no organizations available", async () => { + component.config.organizations = []; + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true); + }); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index bc5e7c43d12..978675e6ad9 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -225,10 +225,9 @@ export class ItemDetailsSectionComponent implements OnInit { }); await this.updateCollectionOptions(this.initialValues?.collectionIds); } + this.setFormState(); - if (!this.allowOwnershipChange) { - this.itemDetailsForm.controls.organizationId.disable(); - } + this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), @@ -241,22 +240,28 @@ export class ItemDetailsSectionComponent implements OnInit { } /** - * When the cipher does not belong to an organization but the user's organization - * requires all ciphers to be owned by an organization, disable the entire form - * until the user selects an organization. Once the organization is set, enable the form. - * Ensure to properly set the collections control state when the form is enabled. + * Updates the global form and organizationId control states. */ private setFormState() { if (this.config.originalCipher && !this.allowPersonalOwnership) { + // When editing a cipher and the user cannot have personal ownership + // and the cipher is is not within the organization - force the user to + // move the cipher within the organization first before editing any other field if (this.itemDetailsForm.controls.organizationId.value === null) { this.cipherFormContainer.disableFormFields(); this.itemDetailsForm.controls.organizationId.enable(); this.favoriteButtonDisabled = true; } else { + // The "after" from the above: When editing a cipher and the user cannot have personal ownership + // and the organization is populated - re-enable the global form. this.cipherFormContainer.enableFormFields(); this.favoriteButtonDisabled = false; this.setCollectionControlState(); } + } else if (!this.allowOwnershipChange) { + // When the user cannot change the organization field, disable the organizationId control. + // This could be because they aren't a part of an organization + this.itemDetailsForm.controls.organizationId.disable({ emitEvent: false }); } } From a48c10283758fc792c282d595b28361e52cb207c Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:52:30 -0700 Subject: [PATCH 23/25] fix(set-password-copy): [Auth/PM-25119] Update copy for flows where the user is setting and initial password (#16169) Updates the copy on flows where the user is setting an initial password. Instead of saying "New master password" and "Confirm new master password", it should say "Master password" and "Confirm master password" for these flows. --- .../input-password/input-password.component.html | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index d56fe6a27fc..b6dc4141c27 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -33,7 +33,12 @@
- {{ "newMasterPass" | i18n }} + {{ + flow === InputPasswordFlow.SetInitialPasswordAccountRegistration || + flow === InputPasswordFlow.SetInitialPasswordAuthedUser + ? ("masterPassword" | i18n) + : ("newMasterPass" | i18n) + }} - {{ "confirmNewMasterPass" | i18n }} + {{ + flow === InputPasswordFlow.SetInitialPasswordAccountRegistration || + flow === InputPasswordFlow.SetInitialPasswordAuthedUser + ? ("confirmMasterPassword" | i18n) + : ("confirmNewMasterPass" | i18n) + }} Date: Thu, 4 Sep 2025 18:05:18 +0200 Subject: [PATCH 24/25] Remove limitation on ssh agent + collections (#15441) --- .../desktop/src/autofill/services/ssh-agent.service.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts index 3909e76689a..d5aed7f3289 100644 --- a/apps/desktop/src/autofill/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -153,10 +153,7 @@ export class SshAgentService implements OnDestroy { if (isListRequest) { const sshCiphers = ciphers.filter( - (cipher) => - cipher.type === CipherType.SshKey && - !cipher.isDeleted && - cipher.organizationId == null, + (cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted, ); const keys = sshCiphers.map((cipher) => { return { @@ -266,10 +263,7 @@ export class SshAgentService implements OnDestroy { } const sshCiphers = ciphers.filter( - (cipher) => - cipher.type === CipherType.SshKey && - !cipher.isDeleted && - cipher.organizationId == null, + (cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted, ); const keys = sshCiphers.map((cipher) => { return { From 896f54696b1dd8891d90e884476768a9004d7aee Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Thu, 4 Sep 2025 09:33:39 -0700 Subject: [PATCH 25/25] [PM-24158] Add Premium Check (#16042) * [PM-24158] Add initial premium check * [PM-24158] Add premium membership dialog fix * [PM-24158] Small updates * [PM-24158] Set hasPremium to false upon initialization * [PM-24158] Partial update to settings component tests * [PM-24158] Fix billing mocked return value and add mac OS autotype test * [PM-24158] Add missing premium checks * [PM-24158] Update provider * [PM-24158] Renamed autotype resolved value * [PM-24158] Update missed resolvedAutotypeEnabled refactor * [PM-24158] Fix tests --- .../src/app/accounts/settings.component.html | 43 ++++++++++++------- .../app/accounts/settings.component.spec.ts | 33 +++++++++++++- .../src/app/accounts/settings.component.ts | 43 ++++++++++++++++--- .../src/app/services/services.module.ts | 2 + .../services/desktop-autotype.service.ts | 21 ++++++--- .../app/accounts/premium.component.html | 2 +- apps/desktop/src/locales/en/messages.json | 9 ++++ 7 files changed, 122 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 091864e59ae..4af12903a24 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -330,6 +330,33 @@ "enableBrowserIntegrationFingerprintDesc" | i18n }}
+
+
+ +
+ + {{ "important" | i18n }} + {{ "enableAutotypeDescriptionTransitionKey" | i18n }} + {{ "editShortcut" | i18n }} +
-
-
- -
- {{ "important" | i18n }} {{ "enableAutotypeDescription" | i18n }} -