diff --git a/apps/browser/src/autofill/content/abstractions/content-message-handler.ts b/apps/browser/src/autofill/content/abstractions/content-message-handler.ts index 6e1fda71a55..8231bd688c9 100644 --- a/apps/browser/src/autofill/content/abstractions/content-message-handler.ts +++ b/apps/browser/src/autofill/content/abstractions/content-message-handler.ts @@ -16,6 +16,7 @@ type ContentMessageWindowEventHandlers = { authResult: ({ data, referrer }: ContentMessageWindowEventParams) => void; webAuthnResult: ({ data, referrer }: ContentMessageWindowEventParams) => void; duoResult: ({ data, referrer }: ContentMessageWindowEventParams) => void; + checkIfBWExtensionInstalled: () => void; }; export { diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index 03055961d7d..07532e606da 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -25,6 +25,17 @@ describe("ContentMessageHandler", () => { jest.clearAllMocks(); }); + describe("handled web vault extension response", () => { + it("sends a message 'hasBWInstalled'", () => { + const mockPostMessage = jest.fn(); + window.postMessage = mockPostMessage; + + postWindowMessage({ command: "checkIfBWExtensionInstalled" }); + + expect(mockPostMessage).toHaveBeenCalled(); + }); + }); + describe("handled window messages", () => { it("ignores messages from other sources", () => { postWindowMessage({ command: "authResult" }, "https://localhost/", null); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index 0cb9f45b43e..8df9b90e11c 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -24,10 +24,18 @@ const windowMessageHandlers: ContentMessageWindowEventHandlers = { handleAuthResultMessage(data, referrer), webAuthnResult: ({ data, referrer }: { data: any; referrer: string }) => handleWebAuthnResultMessage(data, referrer), + checkIfBWExtensionInstalled: () => handleExtensionInstallCheck(), duoResult: ({ data, referrer }: { data: any; referrer: string }) => handleDuoResultMessage(data, referrer), }; +/** + * Handles the post to the web vault showing the extension has been installed + */ +function handleExtensionInstallCheck() { + window.postMessage({ command: "hasBWInstalled" }); +} + /** * Handles the auth result message from the window. * diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html index a6a71c3df8a..ae6ff2c36b2 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html @@ -27,7 +27,12 @@ - {{ "onboardingImportDataDetailsPartTwo" | i18n }} + + {{ "onboardingImportDataDetailsPartTwoNoOrgs" | i18n }} + + + {{ "onboardingImportDataDetailsPartTwoWithOrgs" | i18n }} +

@@ -35,6 +40,8 @@ [title]="'installBrowserExtension' | i18n" icon="bwi-cli" (click)="navigateToExtension()" + route="[]" + [completed]="onboardingTasks.installExtension" > {{ "installBrowserExtensionDetails" | i18n }} diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts index 6bf9609898a..7e7660ddbf8 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { Subject, of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -38,11 +38,9 @@ describe("VaultOnboardingComponent", () => { mockStateProvider = { getActive: jest.fn().mockReturnValue( of({ - vaultTasks: { - createAccount: true, - importData: false, - installExtension: false, - }, + createAccount: true, + importData: false, + installExtension: false, }), ), }; @@ -61,9 +59,6 @@ describe("VaultOnboardingComponent", () => { { provide: StateProvider, useValue: mockStateProvider }, ], }).compileComponents(); - }); - - beforeEach(() => { fixture = TestBed.createComponent(VaultOnboardingComponent); component = fixture.componentInstance; setInstallExtLinkSpy = jest.spyOn(component, "setInstallExtLink"); @@ -71,6 +66,7 @@ describe("VaultOnboardingComponent", () => { .spyOn(component, "individualVaultPolicyCheck") .mockReturnValue(undefined); jest.spyOn(component, "checkCreationDate").mockReturnValue(null); + jest.spyOn(window, "postMessage").mockImplementation(jest.fn()); (component as any).vaultOnboardingService.vaultOnboardingState$ = of({ createAccount: true, importData: false, @@ -143,4 +139,43 @@ describe("VaultOnboardingComponent", () => { expect(spy).toHaveBeenCalled(); }); }); + + describe("checkBrowserExtension", () => { + it("should call getMessages when showOnboarding is true", () => { + const messageEventSubject = new Subject(); + const messageEvent = new MessageEvent("message", { data: "hasBWInstalled" }); + const getMessagesSpy = jest.spyOn(component, "getMessages"); + + (component as any).showOnboarding = true; + component.checkForBrowserExtension(); + messageEventSubject.next(messageEvent); + + void fixture.whenStable().then(() => { + expect(window.postMessage).toHaveBeenCalledWith({ command: "checkIfBWExtensionInstalled" }); + expect(getMessagesSpy).toHaveBeenCalled(); + }); + }); + + it("should set installExtension to true when hasBWInstalled command is passed", async () => { + const saveCompletedTasksSpy = jest.spyOn( + (component as any).vaultOnboardingService, + "setVaultOnboardingTasks", + ); + + (component as any).vaultOnboardingService.vaultOnboardingState$ = of({ + createAccount: true, + importData: false, + installExtension: false, + }); + + const eventData = { data: { command: "hasBWInstalled" } }; + + (component as any).showOnboarding = true; + + await component.ngOnInit(); + await component.getMessages(eventData); + + expect(saveCompletedTasksSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index 1243c5b0838..ac7584a88e0 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -9,13 +9,13 @@ import { SimpleChanges, OnChanges, } from "@angular/core"; -import { Router } from "@angular/router"; -import { Subject, takeUntil, Observable, firstValueFrom } from "rxjs"; +import { Subject, takeUntil, Observable, firstValueFrom, fromEvent } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -35,6 +35,7 @@ import { VaultOnboardingTasks } from "./services/vault-onboarding.service"; }) export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { @Input() ciphers: CipherView[]; + @Input() orgs: Organization[]; @Output() onAddCipher = new EventEmitter(); extensionUrl: string; @@ -52,7 +53,6 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { constructor( protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, - protected router: Router, private apiService: ApiService, private configService: ConfigServiceAbstraction, private vaultOnboardingService: VaultOnboardingServiceAbstraction, @@ -67,15 +67,18 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { await this.setOnboardingTasks(); this.setInstallExtLink(); this.individualVaultPolicyCheck(); + this.checkForBrowserExtension(); } async ngOnChanges(changes: SimpleChanges) { if (this.showOnboarding && changes?.ciphers) { - await this.saveCompletedTasks({ + const currentTasks = await firstValueFrom(this.onboardingTasks$); + const updatedTasks = { createAccount: true, importData: this.ciphers.length > 0, - installExtension: false, - }); + installExtension: currentTasks.installExtension, + }; + await this.vaultOnboardingService.setVaultOnboardingTasks(updatedTasks); } } @@ -84,6 +87,30 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { this.destroy$.complete(); } + checkForBrowserExtension() { + if (this.showOnboarding) { + fromEvent(window, "message") + .pipe(takeUntil(this.destroy$)) + .subscribe((event) => { + void this.getMessages(event); + }); + + window.postMessage({ command: "checkIfBWExtensionInstalled" }); + } + } + + async getMessages(event: any) { + if (event.data.command === "hasBWInstalled" && this.showOnboarding) { + const currentTasks = await firstValueFrom(this.onboardingTasks$); + const updatedTasks = { + createAccount: currentTasks.createAccount, + importData: currentTasks.importData, + installExtension: true, + }; + await this.vaultOnboardingService.setVaultOnboardingTasks(updatedTasks); + } + } + async checkCreationDate() { const userProfile = await this.apiService.getProfile(); const profileCreationDate = new Date(userProfile.creationDate); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b0592c58360..b59e554f5ac 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -11,7 +11,8 @@ (onDeleteCollection)="deleteCollection(selectedCollection.node)" > - + +
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 17b98a65341..df97651d935 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1349,13 +1349,17 @@ }, "onboardingImportDataDetailsPartOne": { "message": "If you don't have any data to import, you can create a ", - "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsLink": { "message": "new item", - "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, - "onboardingImportDataDetailsPartTwo": { + "onboardingImportDataDetailsPartTwoNoOrgs": { + "message": " instead.", + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead." + }, + "onboardingImportDataDetailsPartTwoWithOrgs": { "message": " instead. You may need to wait until your administrator confirms your organization membership.", "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." },