From 30ee79d2068cfc4a095b56587ef6fc0965db4231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 18 Feb 2025 12:53:10 -0500 Subject: [PATCH 01/25] add `popupBackAction` to send item and export vault pages (#13363) --- .../popup/layout/popup-back.directive.ts | 26 +++++++++++++++++++ .../add-edit/send-add-edit.component.html | 3 +++ .../add-edit/send-add-edit.component.ts | 2 ++ .../export/export-browser-v2.component.html | 3 +++ .../export/export-browser-v2.component.ts | 2 ++ libs/components/src/index.ts | 2 +- 6 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/browser/src/platform/popup/layout/popup-back.directive.ts diff --git a/apps/browser/src/platform/popup/layout/popup-back.directive.ts b/apps/browser/src/platform/popup/layout/popup-back.directive.ts new file mode 100644 index 00000000000..95f82588640 --- /dev/null +++ b/apps/browser/src/platform/popup/layout/popup-back.directive.ts @@ -0,0 +1,26 @@ +import { Directive, Optional } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { BitActionDirective, ButtonLikeAbstraction } from "@bitwarden/components"; + +import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; + +/** Navigate the browser popup to the previous page when the component is clicked. */ +@Directive({ + selector: "[popupBackAction]", + standalone: true, +}) +export class PopupBackBrowserDirective extends BitActionDirective { + constructor( + buttonComponent: ButtonLikeAbstraction, + private router: PopupRouterCacheService, + @Optional() validationService?: ValidationService, + @Optional() logService?: LogService, + ) { + super(buttonComponent, validationService, logService); + + // override `bitAction` input; the parent handles the rest + this.handler = () => this.router.back(); + } +} diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index 40c942539f6..5d313188d8f 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -16,6 +16,9 @@ + + diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts index 851509ab17f..27147b75d39 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts @@ -7,6 +7,7 @@ import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/compo import { ExportComponent } from "@bitwarden/vault-export-ui"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +import { PopupBackBrowserDirective } from "../../../../platform/popup/layout/popup-back.directive"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; @@ -25,6 +26,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupFooterComponent, PopupHeaderComponent, PopOutComponent, + PopupBackBrowserDirective, ], }) export class ExportBrowserV2Component { diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 7788f4986bf..319b60e6435 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,4 +1,4 @@ -export { ButtonType } from "./shared/button-like.abstraction"; +export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; export * from "./a11y"; export * from "./async-actions"; export * from "./avatar"; From f798760dc5bbc3a43afa7b4e27c9a0f678fbea8e Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 18 Feb 2025 20:21:42 +0100 Subject: [PATCH 02/25] [PM-17948] Migrate export from generator legacy to generator core (#13238) * Migrate export from generator-legacy to generator-core * Remove unused platformUtilsService * Wire up password generation within ngOnInit --------- Co-authored-by: Daniel James Smith --- .../src/components/export.component.ts | 25 +++++++++++++------ .../vault-export-ui/tsconfig.json | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 934c35f8060..c992ecd78cf 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -42,7 +42,6 @@ import { EventType } from "@bitwarden/common/enums"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { AsyncActionsModule, @@ -56,7 +55,8 @@ import { SelectModule, ToastService, } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; +import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -81,6 +81,7 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component"; ExportScopeCalloutComponent, UserVerificationDialogComponent, PasswordStrengthV2Component, + GeneratorServicesModule, ], }) export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { @@ -175,14 +176,14 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { private destroy$ = new Subject(); private onlyManagedCollections = true; + private onGenerate$ = new Subject(); constructor( protected i18nService: I18nService, protected toastService: ToastService, protected exportService: VaultExportServiceAbstraction, protected eventCollectionService: EventCollectionService, - protected passwordGenerationService: PasswordGenerationServiceAbstraction, - private platformUtilsService: PlatformUtilsService, + protected generatorService: CredentialGeneratorService, private policyService: PolicyService, private logService: LogService, private formBuilder: UntypedFormBuilder, @@ -218,6 +219,17 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + // Wire up the password generation for the password-protected export + this.generatorService + .generate$(Generators.password, { on$: this.onGenerate$ }) + .pipe(takeUntil(this.destroy$)) + .subscribe((generated) => { + this.exportForm.patchValue({ + filePassword: generated.credential, + confirmFilePassword: generated.credential, + }); + }); + if (this.organizationId) { this.organizations$ = this.organizationService .memberOrganizations$(userId) @@ -302,10 +314,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } generatePassword = async () => { - const [options] = await this.passwordGenerationService.getOptions(); - const generatedPassword = await this.passwordGenerationService.generatePassword(options); - this.exportForm.get("filePassword").setValue(generatedPassword); - this.exportForm.get("confirmFilePassword").setValue(generatedPassword); + this.onGenerate$.next({ source: "export" }); }; submit = async () => { diff --git a/libs/tools/export/vault-export/vault-export-ui/tsconfig.json b/libs/tools/export/vault-export/vault-export-ui/tsconfig.json index 1732817986e..6f2a0242dac 100644 --- a/libs/tools/export/vault-export/vault-export-ui/tsconfig.json +++ b/libs/tools/export/vault-export/vault-export-ui/tsconfig.json @@ -9,6 +9,7 @@ "@bitwarden/common/*": ["../../../../common/src/*"], "@bitwarden/components": ["../../../../components/src"], "@bitwarden/generator-core": ["../../../../tools/generator/core/src"], + "@bitwarden/generator-components": ["../../../../tools/generator/components/src"], "@bitwarden/generator-history": ["../../../../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../../../../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../../../../tools/generator/extensions/navigation/src"], From a2c23aa661d9d07d298f3fa96fcbf9c586fec2aa Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 18 Feb 2025 15:27:01 -0500 Subject: [PATCH 03/25] PM-15998 - Update browser context menu options when the page domain is a blocked domain (#13378) * update main context menu handler to skip creating menu entries which do not pass blocked uri checks * refactor to remove menu entries which do not pass blocked uri checks * allow context menu autofill items without a password if they have other autofillable attributes * include ciphers without passwords in autofill context menu options and track context menu state --- .../abstractions/main-context-menu-handler.ts | 3 +- .../browser/cipher-context-menu-handler.ts | 6 +- .../browser/main-context-menu-handler.spec.ts | 193 ++++++++++++++---- .../browser/main-context-menu-handler.ts | 77 +++++-- .../browser/src/background/main.background.ts | 30 ++- 5 files changed, 255 insertions(+), 54 deletions(-) diff --git a/apps/browser/src/autofill/browser/abstractions/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/abstractions/main-context-menu-handler.ts index 7ded23116ee..180a4685332 100644 --- a/apps/browser/src/autofill/browser/abstractions/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/abstractions/main-context-menu-handler.ts @@ -1,5 +1,6 @@ type InitContextMenuItems = Omit & { - checkPremiumAccess?: boolean; + requiresPremiumAccess?: boolean; + requiresUnblockedUri?: boolean; }; export { InitContextMenuItems }; diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts index 038f4e85c9a..e2bf75350a2 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts @@ -21,7 +21,7 @@ export class CipherContextMenuHandler { private accountService: AccountService, ) {} - async update(url: string) { + async update(url: string, currentUriIsBlocked: boolean = false) { if (this.mainContextMenuHandler.initRunning) { return; } @@ -88,6 +88,10 @@ export class CipherContextMenuHandler { for (const cipher of ciphers) { await this.updateForCipher(cipher); } + + if (currentUriIsBlocked) { + await this.mainContextMenuHandler.removeBlockedUriMenuItems(); + } } private async updateForCipher(cipher: CipherView) { diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 79998b65205..267a832a671 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -2,7 +2,17 @@ import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { NOOP_COMMAND_SUFFIX } from "@bitwarden/common/autofill/constants"; +import { + AUTOFILL_CARD_ID, + AUTOFILL_ID, + AUTOFILL_IDENTITY_ID, + COPY_IDENTIFIER_ID, + COPY_PASSWORD_ID, + COPY_USERNAME_ID, + COPY_VERIFICATION_CODE_ID, + NOOP_COMMAND_SUFFIX, + SEPARATOR_ID, +} from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -15,6 +25,43 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { MainContextMenuHandler } from "./main-context-menu-handler"; +/** + * Used in place of Set method `symmetricDifference`, which is only available to node version 22.0.0 or greater: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference + */ +function symmetricDifference(setA: Set, setB: Set) { + const _difference = new Set(setA); + for (const elem of setB) { + if (_difference.has(elem)) { + _difference.delete(elem); + } else { + _difference.add(elem); + } + } + return _difference; +} + +const createCipher = (data?: { + id?: CipherView["id"]; + username?: CipherView["login"]["username"]; + password?: CipherView["login"]["password"]; + totp?: CipherView["login"]["totp"]; + viewPassword?: CipherView["viewPassword"]; +}): CipherView => { + const { id, username, password, totp, viewPassword } = data || {}; + const cipherView = new CipherView( + new Cipher({ + id: id ?? "1", + type: CipherType.Login, + viewPassword: viewPassword ?? true, + } as any), + ); + cipherView.login.username = username ?? "USERNAME"; + cipherView.login.password = password ?? "PASSWORD"; + cipherView.login.totp = totp ?? "TOTP"; + return cipherView; +}; + describe("context-menu", () => { let stateService: MockProxy; let autofillSettingsService: MockProxy; @@ -59,6 +106,9 @@ describe("context-menu", () => { billingAccountProfileStateService, accountService, ); + + jest.spyOn(MainContextMenuHandler, "remove"); + autofillSettingsService.enableContextMenu$ = of(true); accountService.activeAccount$ = of({ id: "userId" as UserId, @@ -68,7 +118,10 @@ describe("context-menu", () => { }); }); - afterEach(() => jest.resetAllMocks()); + afterEach(async () => { + await MainContextMenuHandler.removeAll(); + jest.resetAllMocks(); + }); describe("init", () => { it("has menu disabled", async () => { @@ -97,27 +150,6 @@ describe("context-menu", () => { }); describe("loadOptions", () => { - const createCipher = (data?: { - id?: CipherView["id"]; - username?: CipherView["login"]["username"]; - password?: CipherView["login"]["password"]; - totp?: CipherView["login"]["totp"]; - viewPassword?: CipherView["viewPassword"]; - }): CipherView => { - const { id, username, password, totp, viewPassword } = data || {}; - const cipherView = new CipherView( - new Cipher({ - id: id ?? "1", - type: CipherType.Login, - viewPassword: viewPassword ?? true, - } as any), - ); - cipherView.login.username = username ?? "USERNAME"; - cipherView.login.password = password ?? "PASSWORD"; - cipherView.login.totp = totp ?? "TOTP"; - return cipherView; - }; - it("is not a login cipher", async () => { await sut.loadOptions("TEST_TITLE", "1", { ...createCipher(), @@ -128,33 +160,124 @@ describe("context-menu", () => { }); it("creates item for autofill", async () => { - await sut.loadOptions( - "TEST_TITLE", - "1", - createCipher({ - username: "", - totp: "", - viewPassword: false, - }), + const cipher = createCipher({ + username: "", + totp: "", + viewPassword: true, + }); + const optionId = "1"; + await sut.loadOptions("TEST_TITLE", optionId, cipher); + + expect(createSpy).toHaveBeenCalledTimes(2); + + expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(2); + + const expectedMenuItems = new Set([ + AUTOFILL_ID + `_${optionId}`, + COPY_PASSWORD_ID + `_${optionId}`, + ]); + + // @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used + // const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"]) + const expectedReceivedDiff = symmetricDifference( + expectedMenuItems, + MainContextMenuHandler["existingMenuItems"], ); - expect(createSpy).toHaveBeenCalledTimes(1); + expect(expectedReceivedDiff.size).toEqual(0); }); it("create entry for each cipher piece", async () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); - - await sut.loadOptions("TEST_TITLE", "1", createCipher()); + const optionId = "arbitraryString"; + await sut.loadOptions("TEST_TITLE", optionId, createCipher()); expect(createSpy).toHaveBeenCalledTimes(4); + + expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(4); + + const expectedMenuItems = new Set([ + AUTOFILL_ID + `_${optionId}`, + COPY_PASSWORD_ID + `_${optionId}`, + COPY_USERNAME_ID + `_${optionId}`, + COPY_VERIFICATION_CODE_ID + `_${optionId}`, + ]); + + // @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used + // const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"]) + const expectedReceivedDiff = symmetricDifference( + expectedMenuItems, + MainContextMenuHandler["existingMenuItems"], + ); + + expect(expectedReceivedDiff.size).toEqual(0); }); it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); - await sut.loadOptions("TEST_TITLE", "NOOP"); + const optionId = "NOOP"; + await sut.loadOptions("TEST_TITLE", optionId); expect(createSpy).toHaveBeenCalledTimes(6); + + expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(6); + + const expectedMenuItems = new Set([ + AUTOFILL_ID + `_${optionId}`, + COPY_PASSWORD_ID + `_${optionId}`, + COPY_USERNAME_ID + `_${optionId}`, + COPY_VERIFICATION_CODE_ID + `_${optionId}`, + AUTOFILL_CARD_ID + `_${optionId}`, + AUTOFILL_IDENTITY_ID + `_${optionId}`, + ]); + + // @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used + // const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"]) + const expectedReceivedDiff = symmetricDifference( + expectedMenuItems, + MainContextMenuHandler["existingMenuItems"], + ); + + expect(expectedReceivedDiff.size).toEqual(0); + }); + }); + + describe("removeBlockedUriMenuItems", () => { + it("removes menu items that require code injection", async () => { + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + autofillSettingsService.enableContextMenu$ = of(true); + stateService.getIsAuthenticated.mockResolvedValue(true); + + const optionId = "1"; + await sut.loadOptions("TEST_TITLE", optionId, createCipher()); + + await sut.removeBlockedUriMenuItems(); + + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledTimes(5); + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_ID); + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_IDENTITY_ID); + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(AUTOFILL_CARD_ID); + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(SEPARATOR_ID + 2); + expect(MainContextMenuHandler["remove"]).toHaveBeenCalledWith(COPY_IDENTIFIER_ID); + + expect(MainContextMenuHandler["existingMenuItems"].size).toEqual(4); + + const expectedMenuItems = new Set([ + AUTOFILL_ID + `_${optionId}`, + COPY_PASSWORD_ID + `_${optionId}`, + COPY_USERNAME_ID + `_${optionId}`, + COPY_VERIFICATION_CODE_ID + `_${optionId}`, + ]); + + // @TODO Replace with `symmetricDifference` Set method once node 22.0.0 or higher is used + // const expectedReceivedDiff = expectedMenuItems.symmetricDifference(MainContextMenuHandler["existingMenuItems"]) + const expectedReceivedDiff = symmetricDifference( + expectedMenuItems, + MainContextMenuHandler["existingMenuItems"], + ); + + expect(expectedReceivedDiff.size).toEqual(0); }); }); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 41d88439e8f..ad9dc34e501 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -31,6 +31,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { InitContextMenuItems } from "./abstractions/main-context-menu-handler"; export class MainContextMenuHandler { + static existingMenuItems: Set = new Set(); initRunning = false; private initContextMenuItems: InitContextMenuItems[] = [ { @@ -41,6 +42,7 @@ export class MainContextMenuHandler { id: AUTOFILL_ID, parentId: ROOT_ID, title: this.i18nService.t("autoFillLogin"), + requiresUnblockedUri: true, }, { id: COPY_USERNAME_ID, @@ -56,7 +58,7 @@ export class MainContextMenuHandler { id: COPY_VERIFICATION_CODE_ID, parentId: ROOT_ID, title: this.i18nService.t("copyVerificationCode"), - checkPremiumAccess: true, + requiresPremiumAccess: true, }, { id: SEPARATOR_ID + 1, @@ -67,16 +69,19 @@ export class MainContextMenuHandler { id: AUTOFILL_IDENTITY_ID, parentId: ROOT_ID, title: this.i18nService.t("autoFillIdentity"), + requiresUnblockedUri: true, }, { id: AUTOFILL_CARD_ID, parentId: ROOT_ID, title: this.i18nService.t("autoFillCard"), + requiresUnblockedUri: true, }, { id: SEPARATOR_ID + 2, type: "separator", parentId: ROOT_ID, + requiresUnblockedUri: true, }, { id: GENERATE_PASSWORD_ID, @@ -87,6 +92,7 @@ export class MainContextMenuHandler { id: COPY_IDENTIFIER_ID, parentId: ROOT_ID, title: this.i18nService.t("copyElementIdentifier"), + requiresUnblockedUri: true, }, ]; private noCardsContextMenuItems: chrome.contextMenus.CreateProperties[] = [ @@ -175,13 +181,19 @@ export class MainContextMenuHandler { this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), ); - for (const options of this.initContextMenuItems) { - if (options.checkPremiumAccess && !hasPremium) { + for (const menuItem of this.initContextMenuItems) { + const { + requiresPremiumAccess, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + requiresUnblockedUri, // destructuring this out of being passed to `create` + ...otherOptions + } = menuItem; + + if (requiresPremiumAccess && !hasPremium) { continue; } - delete options.checkPremiumAccess; - await MainContextMenuHandler.create({ ...options, contexts: ["all"] }); + await MainContextMenuHandler.create({ ...otherOptions, contexts: ["all"] }); } } catch (error) { this.logService.warning(error.message); @@ -202,12 +214,16 @@ export class MainContextMenuHandler { } return new Promise((resolve, reject) => { - chrome.contextMenus.create(options, () => { + const itemId = chrome.contextMenus.create(options, () => { if (chrome.runtime.lastError) { return reject(chrome.runtime.lastError); } resolve(); }); + + this.existingMenuItems.add(`${itemId}`); + + return itemId; }); }; @@ -221,12 +237,16 @@ export class MainContextMenuHandler { resolve(); }); + + this.existingMenuItems = new Set(); + + return; }); } static remove(menuItemId: string) { return new Promise((resolve, reject) => { - chrome.contextMenus.remove(menuItemId, () => { + const itemId = chrome.contextMenus.remove(menuItemId, () => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError); return; @@ -234,6 +254,10 @@ export class MainContextMenuHandler { resolve(); }); + + this.existingMenuItems.delete(`${itemId}`); + + return; }); } @@ -244,6 +268,11 @@ export class MainContextMenuHandler { const createChildItem = async (parentId: string) => { const menuItemId = `${parentId}_${optionId}`; + const itemAlreadyExists = MainContextMenuHandler.existingMenuItems.has(menuItemId); + if (itemAlreadyExists) { + return; + } + return await MainContextMenuHandler.create({ type: "normal", id: menuItemId, @@ -255,10 +284,18 @@ export class MainContextMenuHandler { if ( !cipher || - (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password)) + (cipher.type === CipherType.Login && + (!Utils.isNullOrEmpty(cipher.login?.username) || + !Utils.isNullOrEmpty(cipher.login?.password) || + !Utils.isNullOrEmpty(cipher.login?.totp))) ) { await createChildItem(AUTOFILL_ID); + } + if ( + !cipher || + (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password)) + ) { if (cipher?.viewPassword ?? true) { await createChildItem(COPY_PASSWORD_ID); } @@ -305,10 +342,22 @@ export class MainContextMenuHandler { } } + async removeBlockedUriMenuItems() { + try { + for (const menuItem of this.initContextMenuItems) { + if (menuItem.requiresUnblockedUri && menuItem.id) { + await MainContextMenuHandler.remove(menuItem.id); + } + } + } catch (error) { + this.logService.warning(error.message); + } + } + async noCards() { try { - for (const option of this.noCardsContextMenuItems) { - await MainContextMenuHandler.create(option); + for (const menuItem of this.noCardsContextMenuItems) { + await MainContextMenuHandler.create(menuItem); } } catch (error) { this.logService.warning(error.message); @@ -317,8 +366,8 @@ export class MainContextMenuHandler { async noIdentities() { try { - for (const option of this.noIdentitiesContextMenuItems) { - await MainContextMenuHandler.create(option); + for (const menuItem of this.noIdentitiesContextMenuItems) { + await MainContextMenuHandler.create(menuItem); } } catch (error) { this.logService.warning(error.message); @@ -327,8 +376,8 @@ export class MainContextMenuHandler { async noLogins() { try { - for (const option of this.noLoginsContextMenuItems) { - await MainContextMenuHandler.create(option); + for (const menuItem of this.noLoginsContextMenuItems) { + await MainContextMenuHandler.create(menuItem); } await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d2b51c7ef40..1c6d018a82c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1374,17 +1374,41 @@ export default class MainBackground { return; } - await this.mainContextMenuHandler?.init(); + const contextMenuIsEnabled = await this.mainContextMenuHandler?.init(); + if (!contextMenuIsEnabled) { + this.onUpdatedRan = this.onReplacedRan = false; + return; + } const tab = await BrowserApi.getTabFromCurrentWindow(); + if (tab) { - await this.cipherContextMenuHandler?.update(tab.url); + const currentUriIsBlocked = await firstValueFrom( + this.domainSettingsService.blockedInteractionsUris$.pipe( + map((blockedInteractionsUris) => { + if (blockedInteractionsUris && tab?.url?.length) { + const tabURL = new URL(tab.url); + const tabIsBlocked = Object.keys(blockedInteractionsUris).some((blockedHostname) => + tabURL.hostname.endsWith(blockedHostname), + ); + + if (tabIsBlocked) { + return true; + } + } + + return false; + }), + ), + ); + + await this.cipherContextMenuHandler?.update(tab.url, currentUriIsBlocked); this.onUpdatedRan = this.onReplacedRan = false; } } async updateOverlayCiphers() { - // overlayBackground null in popup only contexts + // `overlayBackground` is null in popup only contexts if (this.overlayBackground) { await this.overlayBackground.updateOverlayCiphers(); } From 6e4a06dab405157e8673f1c9a0925534f7b169ba Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 18 Feb 2025 15:29:47 -0500 Subject: [PATCH 04/25] [CL-317] Use storybook theme addon for theme switching (#13451) --- .github/renovate.json5 | 1 + .storybook/main.ts | 1 + .storybook/preview.tsx | 65 ++++++------------- libs/components/src/drawer/drawer.stories.ts | 6 +- .../kitchen-sink/kitchen-sink.stories.ts | 3 +- .../src/stories/storybook-decorators.ts | 12 ---- package-lock.json | 17 +++++ package.json | 1 + 8 files changed, 41 insertions(+), 65 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 048d88e4f62..6d6fbbd2539 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -209,6 +209,7 @@ "@storybook/addon-essentials", "@storybook/addon-interactions", "@storybook/addon-links", + "@storybook/addon-themes", "@storybook/angular", "@storybook/manager-api", "@storybook/theming", diff --git a/.storybook/main.ts b/.storybook/main.ts index d98ca06ead3..9583d1fc6f2 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -29,6 +29,7 @@ const config: StorybookConfig = { getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-designs"), getAbsolutePath("@storybook/addon-interactions"), + getAbsolutePath("@storybook/addon-themes"), { // @storybook/addon-docs is part of @storybook/addon-essentials // eslint-disable-next-line storybook/no-uninstalled-addons diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 85515068b3a..6bd28cfe809 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,60 +1,30 @@ import { setCompodocJson } from "@storybook/addon-docs/angular"; +import { withThemeByClassName } from "@storybook/addon-themes"; import { componentWrapperDecorator } from "@storybook/angular"; import type { Preview } from "@storybook/angular"; import docJson from "../documentation.json"; setCompodocJson(docJson); -const decorator = componentWrapperDecorator( - (story) => { - return /*html*/ ` -
- ${story} -
+const wrapperDecorator = componentWrapperDecorator((story) => { + return /*html*/ ` +
+ ${story} +
`; - }, - ({ globals }) => { - // We need to add the theme class to the body to support body-appended content like popovers and menus - document.body.classList.remove("theme_light"); - document.body.classList.remove("theme_dark"); - - document.body.classList.add(`theme_${globals["theme"]}`); - - return { theme: `${globals["theme"]}` }; - }, -); +}); const preview: Preview = { - decorators: [decorator], - globalTypes: { - theme: { - description: "Global theme for components", - defaultValue: "light", - toolbar: { - title: "Theme", - icon: "circlehollow", - items: [ - { - title: "Light", - value: "light", - icon: "sun", - }, - { - title: "Dark", - value: "dark", - icon: "moon", - }, - ], - dynamicTitle: true, + decorators: [ + withThemeByClassName({ + themes: { + light: "theme_light", + dark: "theme_dark", }, - }, - }, + defaultTheme: "light", + }), + wrapperDecorator, + ], parameters: { controls: { matchers: { @@ -69,6 +39,9 @@ const preview: Preview = { }, }, docs: { source: { type: "dynamic", excludeDecorators: true } }, + backgrounds: { + disable: true, + }, }, tags: ["autodocs"], }; diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts index 54b4c89f4ce..a524c9a7a1a 100644 --- a/libs/components/src/drawer/drawer.stories.ts +++ b/libs/components/src/drawer/drawer.stories.ts @@ -9,10 +9,7 @@ import { ButtonModule } from "../button"; import { CalloutModule } from "../callout"; import { LayoutComponent } from "../layout"; import { mockLayoutI18n } from "../layout/mocks"; -import { - disableBothThemeDecorator, - positionFixedWrapperDecorator, -} from "../stories/storybook-decorators"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { TypographyModule } from "../typography"; import { I18nMockService } from "../utils"; @@ -30,7 +27,6 @@ export default { }, decorators: [ positionFixedWrapperDecorator(), - disableBothThemeDecorator, moduleMetadata({ imports: [ RouterTestingModule, diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index 62b93984384..af3b082d1c6 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { DialogService } from "../../dialog"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; -import { disableBothThemeDecorator, positionFixedWrapperDecorator } from "../storybook-decorators"; +import { positionFixedWrapperDecorator } from "../storybook-decorators"; import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component"; import { KitchenSinkForm } from "./components/kitchen-sink-form.component"; @@ -31,7 +31,6 @@ export default { component: LayoutComponent, decorators: [ positionFixedWrapperDecorator(), - disableBothThemeDecorator, moduleMetadata({ imports: [ KitchenSinkSharedModule, diff --git a/libs/components/src/stories/storybook-decorators.ts b/libs/components/src/stories/storybook-decorators.ts index ec0df264c7e..d1146a7cd96 100644 --- a/libs/components/src/stories/storybook-decorators.ts +++ b/libs/components/src/stories/storybook-decorators.ts @@ -17,15 +17,3 @@ export const positionFixedWrapperDecorator = (wrapper?: (story: string) => strin ${wrapper ? wrapper(story) : story} `, ); - -export const disableBothThemeDecorator = componentWrapperDecorator( - (story) => story, - ({ globals }) => { - /** - * avoid a bug with the way that we render the same component twice in the same iframe and how - * that interacts with the router-outlet - */ - const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; - return { theme: themeOverride }; - }, -); diff --git a/package-lock.json b/package-lock.json index 59635383625..744e137009c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "@storybook/addon-essentials": "8.5.2", "@storybook/addon-interactions": "8.5.2", "@storybook/addon-links": "8.5.2", + "@storybook/addon-themes": "^8.5.2", "@storybook/angular": "8.5.2", "@storybook/manager-api": "8.5.2", "@storybook/theming": "8.5.2", @@ -8713,6 +8714,22 @@ "storybook": "^8.5.2" } }, + "node_modules/@storybook/addon-themes": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-8.5.2.tgz", + "integrity": "sha512-MTJkPwXqLK2Co186EUw2wr+1CpVRMbuWsOmQvhMHeU704kQtSYKkhu/xmaExuDYMupn5xiKG0p8Pt5Ck3fEObQ==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.5.2" + } + }, "node_modules/@storybook/addon-toolbars": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.5.2.tgz", diff --git a/package.json b/package.json index 4762bad20ad..e25493335bc 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@storybook/addon-essentials": "8.5.2", "@storybook/addon-interactions": "8.5.2", "@storybook/addon-links": "8.5.2", + "@storybook/addon-themes": "8.5.2", "@storybook/angular": "8.5.2", "@storybook/manager-api": "8.5.2", "@storybook/theming": "8.5.2", From 993c056b19334538288edad8334907a07a6331d7 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 18 Feb 2025 12:41:42 -0800 Subject: [PATCH 05/25] [PM-18055] - sync list and item view after saving vault item (#13412) * sync list and item view after saving vault item * sync folder on save * remove unused destroy ref --- .../src/vault/app/vault/vault.component.ts | 13 ++++++++----- .../src/vault/components/view.component.ts | 19 ++++++------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index aba7353c5e4..6f844a7bf51 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -28,7 +28,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -90,6 +90,7 @@ export class VaultComponent implements OnInit, OnDestroy { deleted = false; userHasPremiumAccess = false; activeFilter: VaultFilter = new VaultFilter(); + activeUserId: UserId; private modal: ModalRef = null; private componentIsDestroyed$ = new Subject(); @@ -237,12 +238,12 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); this.cipherService - .failedToDecryptCiphers$(activeUserId) + .failedToDecryptCiphers$(this.activeUserId) .pipe( - map((ciphers) => ciphers.filter((c) => !c.isDeleted)), + map((ciphers) => ciphers?.filter((c) => !c.isDeleted) ?? []), filter((ciphers) => ciphers.length > 0), take(1), takeUntil(this.componentIsDestroyed$), @@ -494,8 +495,10 @@ export class VaultComponent implements OnInit, OnDestroy { async savedCipher(cipher: CipherView) { this.cipherId = cipher.id; this.action = "view"; - this.go(); await this.vaultItemsComponent.refresh(); + await this.cipherService.clearCache(this.activeUserId); + await this.viewComponent.load(); + this.go(); } async deletedCipher(cipher: CipherView) { diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 637596256b0..92a231ab8db 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -11,7 +11,7 @@ import { OnInit, Output, } from "@angular/core"; -import { filter, firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; +import { filter, firstValueFrom, map, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -80,8 +80,6 @@ export class ViewComponent implements OnDestroy, OnInit { private previousCipherId: string; private passwordReprompted = false; - private destroyed$ = new Subject(); - get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -144,18 +142,14 @@ export class ViewComponent implements OnDestroy, OnInit { async load() { this.cleanUp(); - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); // Grab individual cipher from `cipherViews$` for the most up-to-date information - this.cipherService - .cipherViews$(activeUserId) - .pipe( + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$(activeUserId).pipe( map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)), filter((cipher) => !!cipher), - takeUntil(this.destroyed$), - ) - .subscribe((cipher) => { - this.cipher = cipher; - }); + ), + ); this.canAccessPremium = await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), @@ -528,7 +522,6 @@ export class ViewComponent implements OnDestroy, OnInit { this.showCardNumber = false; this.showCardCode = false; this.passwordReprompted = false; - this.destroyed$.next(); if (this.totpInterval) { clearInterval(this.totpInterval); } From 4c09c228060fbe1b2fe78552220ee405e6ddaf22 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Tue, 18 Feb 2025 16:37:27 -0500 Subject: [PATCH 06/25] PM-18215 Create UI for confirmation notification message (#13364) * PM-18215 wip * update storybook url * optional errors and storybook args * type safety * Update apps/browser/src/autofill/content/components/icons/warning.ts Co-authored-by: Jonathan Prusik * updated svg to remove dark or light --------- Co-authored-by: Jonathan Prusik --- .../content/components/icons/index.ts | 1 + .../content/components/icons/party-horn.ts | 328 +++++++++++------- .../content/components/icons/warning.ts | 23 ++ .../notification/confirmation.lit-stories.ts | 41 +++ .../notification/confirmation-message.ts | 54 +++ .../components/notification/confirmation.ts | 58 ++++ 6 files changed, 388 insertions(+), 117 deletions(-) create mode 100644 apps/browser/src/autofill/content/components/icons/warning.ts create mode 100644 apps/browser/src/autofill/content/components/lit-stories/notification/confirmation.lit-stories.ts create mode 100644 apps/browser/src/autofill/content/components/notification/confirmation-message.ts create mode 100644 apps/browser/src/autofill/content/components/notification/confirmation.ts diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts index 992b034afa7..6cc56e079d4 100644 --- a/apps/browser/src/autofill/content/components/icons/index.ts +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -10,3 +10,4 @@ export { PartyHorn } from "./party-horn"; export { PencilSquare } from "./pencil-square"; export { Shield } from "./shield"; export { User } from "./user"; +export { Warning } from "./warning"; diff --git a/apps/browser/src/autofill/content/components/icons/party-horn.ts b/apps/browser/src/autofill/content/components/icons/party-horn.ts index dc2144b524f..e807df1d86e 100644 --- a/apps/browser/src/autofill/content/components/icons/party-horn.ts +++ b/apps/browser/src/autofill/content/components/icons/party-horn.ts @@ -6,168 +6,262 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; export function PartyHorn({ theme }: { theme: Theme }) { if (theme === ThemeTypes.Dark) { return html` - + - - - - + + + + + + + + + - + + + + `; } return html` - + - - + + + + + + + - - - + + + - + + + + `; diff --git a/apps/browser/src/autofill/content/components/icons/warning.ts b/apps/browser/src/autofill/content/components/icons/warning.ts new file mode 100644 index 00000000000..9ae9aeca352 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/warning.ts @@ -0,0 +1,23 @@ +import { html } from "lit"; + +// This icon has static multi-colors for each theme +export function Warning() { + return html` + + + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation.lit-stories.ts new file mode 100644 index 00000000000..94dbaace9aa --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation.lit-stories.ts @@ -0,0 +1,41 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { NotificationConfirmationBody } from "../../notification/confirmation"; + +type Args = { + buttonText: string; + confirmationMessage: string; + handleClick: () => void; + theme: Theme; + error: string; +}; + +export default { + title: "Components/Notifications/Notification Confirmation Body", + argTypes: { + error: { control: "text" }, + buttonText: { control: "text" }, + confirmationMessage: { control: "text" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + }, + args: { + error: "", + buttonText: "View", + confirmationMessage: "[item name] updated in Bitwarden.", + theme: ThemeTypes.Light, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=485-20160&m=dev", + }, + }, +} as Meta; + +const Template = (args: Args) => NotificationConfirmationBody({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/notification/confirmation-message.ts b/apps/browser/src/autofill/content/components/notification/confirmation-message.ts new file mode 100644 index 00000000000..745899481dd --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/confirmation-message.ts @@ -0,0 +1,54 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { themes } from "../constants/styles"; + +export function NotificationConfirmationMessage({ + buttonText, + confirmationMessage, + handleClick, + theme, +}: { + buttonText: string; + confirmationMessage: string; + handleClick: (e: Event) => void; + theme: Theme; +}) { + return html` + ${confirmationMessage} + ${buttonText} + `; +} + +const baseTextStyles = css` + flex-grow: 1; + overflow-x: hidden; + text-align: left; + text-overflow: ellipsis; + line-height: 24px; + white-space: nowrap; + font-family: "DM Sans", sans-serif; + font-size: 16px; +`; + +const notificationConfirmationMessageStyles = (theme: Theme) => css` + ${baseTextStyles} + color: ${themes[theme].text.main}; + font-weight: 400; +`; + +const notificationConfirmationButtonTextStyles = (theme: Theme) => css` + ${baseTextStyles} + color: ${themes[theme].primary[600]}; + font-weight: 700; + cursor: pointer; +`; diff --git a/apps/browser/src/autofill/content/components/notification/confirmation.ts b/apps/browser/src/autofill/content/components/notification/confirmation.ts new file mode 100644 index 00000000000..0c389f75eb6 --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/confirmation.ts @@ -0,0 +1,58 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { themes } from "../constants/styles"; +import { PartyHorn, Warning } from "../icons"; + +import { NotificationConfirmationMessage } from "./confirmation-message"; + +export const componentClassPrefix = "notification-confirmation-body"; + +const { css } = createEmotion({ + key: componentClassPrefix, +}); + +export function NotificationConfirmationBody({ + buttonText, + error, + confirmationMessage, + theme = ThemeTypes.Light, +}: { + error?: string; + buttonText: string; + confirmationMessage: string; + theme: Theme; +}) { + const IconComponent = !error ? PartyHorn : Warning; + return html` +
+
${IconComponent({ theme })}
+ ${confirmationMessage && buttonText + ? NotificationConfirmationMessage({ + handleClick: () => {}, + confirmationMessage, + theme, + buttonText, + }) + : null} +
+ `; +} + +const iconContainerStyles = (error?: string) => css` + > svg { + width: ${!error ? "50px" : "40px"}; + height: fit-content; + } +`; +const notificationConfirmationBodyStyles = ({ theme }: { theme: Theme }) => css` + gap: 16px; + display: flex; + align-items: center; + justify-content: flex-start; + background-color: ${themes[theme].background.alt}; + padding: 12px; + white-space: nowrap; +`; From fa8ee6fa022559ebf256a808c9bf6bcc250c4a70 Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Tue, 18 Feb 2025 16:52:29 -0600 Subject: [PATCH 07/25] feat(auth): [PM-15534] log user in when submitting recovery code - Add recovery code enum and feature flag - Update recovery code text and warning messages - Log user in and redirect to two-factor settings page on valid recovery code - Run full sync and handle login errors silently - Move updated messaging behind feature flag PM-15534 --- .../settings/two-factor-setup.component.ts | 6 + .../auth/recover-two-factor.component.html | 2 +- .../app/auth/recover-two-factor.component.ts | 146 +++++++++++++++--- .../two-factor-setup.component.html | 2 +- .../two-factor/two-factor-setup.component.ts | 13 ++ apps/web/src/locales/en/messages.json | 6 + .../auth/enums/two-factor-provider-type.ts | 1 + libs/common/src/enums/feature-flag.enum.ts | 2 + 8 files changed, 158 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index d07f674e813..323e5326a1c 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -18,6 +18,8 @@ import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService } from "@bitwarden/components"; @@ -41,6 +43,8 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme private organizationService: OrganizationService, billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, + configService: ConfigService, + i18nService: I18nService, ) { super( dialogService, @@ -49,6 +53,8 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme policyService, billingAccountProfileStateService, accountService, + configService, + i18nService, ); } diff --git a/apps/web/src/app/auth/recover-two-factor.component.html b/apps/web/src/app/auth/recover-two-factor.component.html index e3641765800..dee3bec1520 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.html +++ b/apps/web/src/app/auth/recover-two-factor.component.html @@ -1,6 +1,6 @@

- {{ "recoverAccountTwoStepDesc" | i18n }} + {{ recoveryCodeMessage }} { this.formGroup.markAllAsTouched(); if (this.formGroup.invalid) { @@ -56,12 +88,90 @@ export class RecoverTwoFactorComponent { request.email = this.email.trim().toLowerCase(); const key = await this.loginStrategyService.makePreloginKey(this.masterPassword, request.email); request.masterPasswordHash = await this.keyService.hashMasterKey(this.masterPassword, key); - await this.apiService.postTwoFactorRecover(request); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("twoStepRecoverDisabled"), - }); - await this.router.navigate(["/"]); + + try { + await this.apiService.postTwoFactorRecover(request); + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), + }); + + if (!this.recoveryCodeLoginFeatureFlagEnabled) { + await this.router.navigate(["/"]); + return; + } + + // Handle login after recovery if the feature flag is enabled + await this.handleRecoveryLogin(request); + } catch (e) { + const errorMessage = this.extractErrorMessage(e); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: errorMessage, + }); + } }; + + /** + * Handles the login process after a successful account recovery. + */ + private async handleRecoveryLogin(request: TwoFactorRecoveryRequest) { + // Build two-factor request to pass into PasswordLoginCredentials request using the 2FA recovery code and RecoveryCode type + const twoFactorRequest: TokenTwoFactorRequest = { + provider: TwoFactorProviderType.RecoveryCode, + token: request.recoveryCode, + remember: false, + }; + + const credentials = new PasswordLoginCredentials( + request.email, + this.masterPassword, + "", + twoFactorRequest, + ); + + try { + const authResult = await this.loginStrategyService.logIn(credentials); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("youHaveBeenLoggedIn"), + }); + await this.loginSuccessHandlerService.run(authResult.userId); + await this.router.navigate(["/settings/security/two-factor"]); + } catch (error) { + // If login errors, redirect to login page per product. Don't show error + this.logService.error("Error logging in automatically: ", (error as Error).message); + await this.router.navigate(["/login"], { queryParams: { email: request.email } }); + } + } + + /** + * Extracts an error message from the error object. + */ + private extractErrorMessage(error: unknown): string { + let errorMessage: string = this.i18nService.t("unexpectedError"); + if (error && typeof error === "object" && "validationErrors" in error) { + const validationErrors = error.validationErrors; + if (validationErrors && typeof validationErrors === "object") { + errorMessage = Object.keys(validationErrors) + .map((key) => { + const messages = (validationErrors as Record)[key]; + return Array.isArray(messages) ? messages.join(" ") : messages; + }) + .join(" "); + } + } else if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + errorMessage = error.message; + } + return errorMessage; + } } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html index b7cd6954fd6..985584e386d 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html @@ -26,7 +26,7 @@

-

{{ "twoStepLoginRecoveryWarning" | i18n }}

+

{{ recoveryCodeWarningMessage }}

diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 4530692ebee..a76505930d4 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -29,6 +29,9 @@ import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.s import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService } from "@bitwarden/components"; @@ -52,6 +55,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { organization: Organization; providers: any[] = []; canAccessPremium$: Observable; + recoveryCodeWarningMessage: string; showPolicyWarning = false; loading = true; modal: ModalRef; @@ -70,6 +74,8 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { protected policyService: PolicyService, billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, + protected configService: ConfigService, + protected i18nService: I18nService, ) { this.canAccessPremium$ = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -79,6 +85,13 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } async ngOnInit() { + const recoveryCodeLoginFeatureFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.RecoveryCodeLogin, + ); + this.recoveryCodeWarningMessage = recoveryCodeLoginFeatureFlagEnabled + ? this.i18nService.t("yourSingleUseRecoveryCode") + : this.i18nService.t("twoStepLoginRecoveryWarning"); + for (const key in TwoFactorProviders) { // eslint-disable-next-line if (!TwoFactorProviders.hasOwnProperty(key)) { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0f48595f09b..3c241003e7a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2183,6 +2183,9 @@ "twoStepLoginRecoveryWarning": { "message": "Setting up two-step login can permanently lock you out of your Bitwarden account. A recovery code allows you to access your account in the event that you can no longer use your normal two-step login provider (example: you lose your device). Bitwarden support will not be able to assist you if you lose access to your account. We recommend you write down or print the recovery code and keep it in a safe place." }, + "yourSingleUseRecoveryCode": { + "message": "Your single-use recovery code can be used to turn off two-step login in the event that you lose access to your two-step login provider. Bitwarden recommends you write down the recovery code and keep it in a safe place." + }, "viewRecoveryCode": { "message": "View recovery code" }, @@ -4193,6 +4196,9 @@ "recoverAccountTwoStepDesc": { "message": "If you cannot access your account through your normal two-step login methods, you can use your two-step login recovery code to turn off all two-step providers on your account." }, + "logInBelowUsingYourSingleUseRecoveryCode": { + "message": "Log in below using your single-use recovery code. This will turn off all two-step providers on your account." + }, "recoverAccountTwoStep": { "message": "Recover account two-step login" }, diff --git a/libs/common/src/auth/enums/two-factor-provider-type.ts b/libs/common/src/auth/enums/two-factor-provider-type.ts index a1708032016..b3308b6c12f 100644 --- a/libs/common/src/auth/enums/two-factor-provider-type.ts +++ b/libs/common/src/auth/enums/two-factor-provider-type.ts @@ -7,4 +7,5 @@ export enum TwoFactorProviderType { Remember = 5, OrganizationDuo = 6, WebAuthn = 7, + RecoveryCode = 8, } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 5137fda329f..75346c0edb8 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 { AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner", NewDeviceVerification = "new-device-verification", PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", + RecoveryCodeLogin = "pm-17128-recovery-code-login", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccountDeprovisioningBanner]: FALSE, [FeatureFlag.NewDeviceVerification]: FALSE, [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, + [FeatureFlag.RecoveryCodeLogin]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; From 39f241db3d11ed4b8669d7ea9230dd624a9ab563 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 19 Feb 2025 13:13:40 +0100 Subject: [PATCH 08/25] [PM-13620]Existing user email linking to create-organization (#13030) * Changes for the existing users * Remove the complicated method * add the column after the patch value * Revert removal of plan query params * Resolve the non blocking issue --- .../create-organization.component.html | 6 ++- .../settings/create-organization.component.ts | 52 +++++++++++-------- .../organization-plans.component.ts | 9 ++++ 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.html b/apps/web/src/app/admin-console/settings/create-organization.component.html index 105a3e6a251..a5acc62dfa9 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.html +++ b/apps/web/src/app/admin-console/settings/create-organization.component.html @@ -2,5 +2,9 @@

{{ "newOrganizationDesc" | i18n }}

- +
diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.ts b/apps/web/src/app/admin-console/settings/create-organization.component.ts index 47cf1c61c5b..7a20826086d 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.ts +++ b/apps/web/src/app/admin-console/settings/create-organization.component.ts @@ -1,10 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; -import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { PlanType, ProductTierType, ProductType } from "@bitwarden/common/billing/enums"; import { OrganizationPlansComponent } from "../../billing"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -15,29 +16,34 @@ import { SharedModule } from "../../shared"; standalone: true, imports: [SharedModule, OrganizationPlansComponent, HeaderModule], }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class CreateOrganizationComponent implements OnInit { - @ViewChild(OrganizationPlansComponent, { static: true }) - orgPlansComponent: OrganizationPlansComponent; +export class CreateOrganizationComponent { + protected secretsManager = false; + protected plan: PlanType = PlanType.Free; + protected productTier: ProductTierType = ProductTierType.Free; - constructor(private route: ActivatedRoute) {} - - ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - if (qParams.plan === "families") { - this.orgPlansComponent.plan = PlanType.FamiliesAnnually; - this.orgPlansComponent.productTier = ProductTierType.Families; - } else if (qParams.plan === "teams") { - this.orgPlansComponent.plan = PlanType.TeamsAnnually; - this.orgPlansComponent.productTier = ProductTierType.Teams; - } else if (qParams.plan === "teamsStarter") { - this.orgPlansComponent.plan = PlanType.TeamsStarter; - this.orgPlansComponent.productTier = ProductTierType.TeamsStarter; - } else if (qParams.plan === "enterprise") { - this.orgPlansComponent.plan = PlanType.EnterpriseAnnually; - this.orgPlansComponent.productTier = ProductTierType.Enterprise; + constructor(private route: ActivatedRoute) { + this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { + if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) { + this.plan = PlanType.FamiliesAnnually; + this.productTier = ProductTierType.Families; + } else if (qParams.plan === "teams" || qParams.productTier == ProductTierType.Teams) { + this.plan = PlanType.TeamsAnnually; + this.productTier = ProductTierType.Teams; + } else if ( + qParams.plan === "teamsStarter" || + qParams.productTier == ProductTierType.TeamsStarter + ) { + this.plan = PlanType.TeamsStarter; + this.productTier = ProductTierType.TeamsStarter; + } else if ( + qParams.plan === "enterprise" || + qParams.productTier == ProductTierType.Enterprise + ) { + this.plan = PlanType.EnterpriseAnnually; + this.productTier = ProductTierType.Enterprise; } + + this.secretsManager = qParams.product == ProductType.SecretsManager; }); } } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 071d1f75161..f9f5b763a4e 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -110,6 +110,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this._plan = plan; this.formGroup?.controls?.plan?.setValue(plan); } + @Input() enableSecretsManagerByDefault: boolean; private _plan = PlanType.Free; @Input() providerId?: string; @@ -269,6 +270,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { .subscribe(() => { this.refreshSalesTax(); }); + + if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) { + this.secretsManagerSubscription.patchValue({ + enabled: true, + userSeats: 1, + additionalServiceAccounts: 0, + }); + } } ngOnDestroy() { From ae38e40859c43cfc8cd55725200cc74a8c9ead58 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 19 Feb 2025 09:18:56 -0500 Subject: [PATCH 09/25] Auth/PM-17693 - Web - Existing users accepting an org invite are required to update password to meet org policy requirements (#13388) * PM-17693 - Refactor all post login logic around getting org policies from invite token and restore lost functionality. * PM-17693 - Add TODO --- .../login/web-login-component.service.spec.ts | 8 +- .../login/web-login-component.service.ts | 2 +- .../default-login-component.service.spec.ts | 7 -- .../login/default-login-component.service.ts | 6 +- .../angular/login/login-component.service.ts | 2 +- .../auth/src/angular/login/login.component.ts | 118 ++++++++++-------- 6 files changed, 70 insertions(+), 73 deletions(-) diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 209af41e311..5d5770e2325 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -74,10 +74,10 @@ describe("WebLoginComponentService", () => { expect(service).toBeTruthy(); }); - describe("getOrgPolicies", () => { + describe("getOrgPoliciesFromOrgInvite", () => { it("returns undefined if organization invite is null", async () => { acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null); - const result = await service.getOrgPolicies(); + const result = await service.getOrgPoliciesFromOrgInvite(); expect(result).toBeUndefined(); }); @@ -94,7 +94,7 @@ describe("WebLoginComponentService", () => { organizationName: "org-name", }); policyApiService.getPoliciesByToken.mockRejectedValue(error); - await service.getOrgPolicies(); + await service.getOrgPoliciesFromOrgInvite(); expect(logService.error).toHaveBeenCalledWith(error); }); @@ -130,7 +130,7 @@ describe("WebLoginComponentService", () => { of(masterPasswordPolicyOptions), ); - const result = await service.getOrgPolicies(); + const result = await service.getOrgPoliciesFromOrgInvite(); expect(result).toEqual({ policies: policies, diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index ce1bce40e39..aa0c204750f 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -48,7 +48,7 @@ export class WebLoginComponentService this.clientType = this.platformUtilsService.getClientType(); } - async getOrgPolicies(): Promise { + async getOrgPoliciesFromOrgInvite(): Promise { const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); if (orgInvite != null) { diff --git a/libs/auth/src/angular/login/default-login-component.service.spec.ts b/libs/auth/src/angular/login/default-login-component.service.spec.ts index 05b24da56cc..446ab44b4ee 100644 --- a/libs/auth/src/angular/login/default-login-component.service.spec.ts +++ b/libs/auth/src/angular/login/default-login-component.service.spec.ts @@ -56,13 +56,6 @@ describe("DefaultLoginComponentService", () => { expect(service).toBeTruthy(); }); - describe("getOrgPolicies", () => { - it("returns null", async () => { - const result = await service.getOrgPolicies(); - expect(result).toBeNull(); - }); - }); - describe("isLoginWithPasskeySupported", () => { it("returns true when clientType is Web", () => { service["clientType"] = ClientType.Web; diff --git a/libs/auth/src/angular/login/default-login-component.service.ts b/libs/auth/src/angular/login/default-login-component.service.ts index 84a7d923d12..41b761ce1d9 100644 --- a/libs/auth/src/angular/login/default-login-component.service.ts +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; -import { LoginComponentService, PasswordPolicies } from "@bitwarden/auth/angular"; +import { LoginComponentService } from "@bitwarden/auth/angular"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -23,10 +23,6 @@ export class DefaultLoginComponentService implements LoginComponentService { protected ssoLoginService: SsoLoginServiceAbstraction, ) {} - async getOrgPolicies(): Promise { - return null; - } - isLoginWithPasskeySupported(): boolean { return this.clientType === ClientType.Web; } diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts index 8ca857cef59..1147c5d8644 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -23,7 +23,7 @@ export abstract class LoginComponentService { * Gets the organization policies if there is an organization invite. * - Used by: Web */ - getOrgPolicies: () => Promise; + getOrgPoliciesFromOrgInvite?: () => Promise; /** * Indicates whether login with passkey is supported on the given client diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 66fe2503508..f31e02fdb1f 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -12,6 +12,7 @@ import { PasswordLoginCredentials, } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; @@ -30,6 +31,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, @@ -43,7 +45,7 @@ import { import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; import { VaultIcon, WaveIcon } from "../icons"; -import { LoginComponentService } from "./login-component.service"; +import { LoginComponentService, PasswordPolicies } from "./login-component.service"; const BroadcasterSubscriptionId = "LoginComponent"; @@ -72,7 +74,6 @@ export class LoginComponent implements OnInit, OnDestroy { @ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined; private destroy$ = new Subject(); - private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions | undefined = undefined; readonly Icons = { WaveIcon, VaultIcon }; clientType: ClientType; @@ -97,11 +98,6 @@ export class LoginComponent implements OnInit, OnDestroy { return this.formGroup.controls.email; } - // Web properties - enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined; - policies: Policy[] | undefined; - showResetPasswordAutoEnrollWarning = false; - // Desktop properties deferFocus: boolean | null = null; @@ -281,18 +277,39 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // User logged in successfully so execute side effects await this.loginSuccessHandlerService.run(authResult.userId); + this.loginEmailService.clearValues(); + // Determine where to send the user next if (authResult.forcePasswordReset != ForceSetPasswordReason.None) { - this.loginEmailService.clearValues(); await this.router.navigate(["update-temp-password"]); return; } - // If none of the above cases are true, proceed with login... - await this.evaluatePassword(); + // TODO: PM-18269 - evaluate if we can combine this with the + // password evaluation done in the password login strategy. + // If there's an existing org invite, use it to get the org's password policies + // so we can evaluate the MP against the org policies + if (this.loginComponentService.getOrgPoliciesFromOrgInvite) { + const orgPolicies: PasswordPolicies | null = + await this.loginComponentService.getOrgPoliciesFromOrgInvite(); - this.loginEmailService.clearValues(); + if (orgPolicies) { + // Since we have retrieved the policies, we can go ahead and set them into state for future use + // e.g., the update-password page currently only references state for policy data and + // doesn't fallback to pulling them from the server like it should if they are null. + await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies); + + const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy( + orgPolicies.enforcedPasswordPolicyOptions, + ); + if (isPasswordChangeRequired) { + await this.router.navigate(["update-password"]); + return; + } + } + } if (this.clientType === ClientType.Browser) { await this.router.navigate(["/tabs/vault"]); @@ -310,54 +327,51 @@ export class LoginComponent implements OnInit, OnDestroy { await this.loginComponentService.launchSsoBrowserWindow(email, clientId); } - protected async evaluatePassword(): Promise { + /** + * Checks if the master password meets the enforced policy requirements + * and if the user is required to change their password. + */ + private async isPasswordChangeRequiredByOrgPolicy( + enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions, + ): Promise { try { - // If we do not have any saved policies, attempt to load them from the service - if (this.enforcedMasterPasswordOptions == undefined) { - this.enforcedMasterPasswordOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(), - ); + if (enforcedPasswordPolicyOptions == undefined) { + return false; } - if (this.requirePasswordChange()) { - await this.router.navigate(["update-password"]); - return; + // Note: we deliberately do not check enforcedPasswordPolicyOptions.enforceOnLogin + // as existing users who are logging in after getting an org invite should + // always be forced to set a password that meets the org's policy. + // Org Invite -> Registration also works this way for new BW users as well. + + const masterPassword = this.formGroup.controls.masterPassword.value; + + // Return false if masterPassword is null/undefined since this is only evaluated after successful login + if (!masterPassword) { + return false; } + + const passwordStrength = this.passwordStrengthService.getPasswordStrength( + masterPassword, + this.formGroup.value.email ?? undefined, + )?.score; + + return !this.policyService.evaluateMasterPassword( + passwordStrength, + masterPassword, + enforcedPasswordPolicyOptions, + ); } catch (e) { // Do not prevent unlock if there is an error evaluating policies this.logService.error(e); + return false; } } - /** - * Checks if the master password meets the enforced policy requirements - * If not, returns false - */ - private requirePasswordChange(): boolean { - if ( - this.enforcedMasterPasswordOptions == undefined || - !this.enforcedMasterPasswordOptions.enforceOnLogin - ) { - return false; - } - - const masterPassword = this.formGroup.controls.masterPassword.value; - - // Return false if masterPassword is null/undefined since this is only evaluated after successful login - if (!masterPassword) { - return false; - } - - const passwordStrength = this.passwordStrengthService.getPasswordStrength( - masterPassword, - this.formGroup.value.email ?? undefined, - )?.score; - - return !this.policyService.evaluateMasterPassword( - passwordStrength, - masterPassword, - this.enforcedMasterPasswordOptions, - ); + private async setPoliciesIntoState(userId: UserId, policies: Policy[]): Promise { + const policiesData: { [id: string]: PolicyData } = {}; + policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p))); + await this.policyService.replace(policiesData, userId); } protected async startAuthRequestLogin(): Promise { @@ -528,12 +542,6 @@ export class LoginComponent implements OnInit, OnDestroy { } private async defaultOnInit(): Promise { - // If there's an existing org invite, use it to get the password policies - const orgPolicies = await this.loginComponentService.getOrgPolicies(); - - this.policies = orgPolicies?.policies; - this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled ?? false; - let paramEmailIsSet = false; const params = await firstValueFrom(this.activatedRoute.queryParams); From 9c102f056c6e55bf93aed5b15ca556b27a5b34b0 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 19 Feb 2025 15:46:43 +0100 Subject: [PATCH 10/25] chore: update sdk to main.105 (#13472) * chore: update sdk version * fix: sdk breaking changes --- libs/angular/src/vault/components/add-edit.component.ts | 6 +++--- .../components/sshkey-section/sshkey-section.component.ts | 6 +++--- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 923f667e680..c309aa9624a 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -827,9 +827,9 @@ export class AddEditComponent implements OnInit, OnDestroy { private async generateSshKey(showNotification: boolean = true) { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); - this.cipher.sshKey.privateKey = sshKey.private_key; - this.cipher.sshKey.publicKey = sshKey.public_key; - this.cipher.sshKey.keyFingerprint = sshKey.key_fingerprint; + this.cipher.sshKey.privateKey = sshKey.privateKey; + this.cipher.sshKey.publicKey = sshKey.publicKey; + this.cipher.sshKey.keyFingerprint = sshKey.fingerprint; if (showNotification) { this.toastService.showToast({ diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 134897c9356..773ddd4ad66 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -104,9 +104,9 @@ export class SshKeySectionComponent implements OnInit { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); this.sshKeyForm.setValue({ - privateKey: sshKey.private_key, - publicKey: sshKey.public_key, - keyFingerprint: sshKey.key_fingerprint, + privateKey: sshKey.privateKey, + publicKey: sshKey.publicKey, + keyFingerprint: sshKey.fingerprint, }); } } diff --git a/package-lock.json b/package-lock.json index 744e137009c..46bf3d23026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@angular/platform-browser": "18.2.13", "@angular/platform-browser-dynamic": "18.2.13", "@angular/router": "18.2.13", - "@bitwarden/sdk-internal": "0.2.0-main.38", + "@bitwarden/sdk-internal": "0.2.0-main.105", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.0.2", @@ -4471,9 +4471,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.38", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.38.tgz", - "integrity": "sha512-bkN+BZC0YA4k0To8QiT33UTZX8peKDXud8Gzq3UHNPlU/vMSkP3Wn8q0GezzmYN3UNNIWXfreNCS0mJ+S51j/Q==", + "version": "0.2.0-main.105", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.105.tgz", + "integrity": "sha512-MaQFJbuKTCbN9oZC/+opYVeegaNNJpiUv9/zx+gu8KxWmX0hyEkNPtHKxBjDt3kLLz69CudDtUxEgqOfcDsYAw==", "license": "GPL-3.0" }, "node_modules/@bitwarden/vault": { diff --git a/package.json b/package.json index e25493335bc..831e99089a4 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "@angular/platform-browser": "18.2.13", "@angular/platform-browser-dynamic": "18.2.13", "@angular/router": "18.2.13", - "@bitwarden/sdk-internal": "0.2.0-main.38", + "@bitwarden/sdk-internal": "0.2.0-main.105", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.0.2", From 661ee036985a4dda23fac9db437350c2b08b892a Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 19 Feb 2025 17:42:34 +0100 Subject: [PATCH 11/25] Add cdk-visually-hidden to tw-theme for Angular CDK LiveAnnouncer (#13410) Angular CKD LiveAnnouncer depends on some css logic to hide announcements from being displayed. This imports the required CSS to ensure the text is hidden from regular view. --- libs/components/src/tw-theme.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 0a5a66337ac..90d424d1285 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -194,6 +194,9 @@ --tw-ring-offset-color: #002b36; } +/** Used by CDK a11y services */ +@import "@angular/cdk/a11y-prebuilt.css"; + @import "./popover/popover.component.css"; @import "./search/search.component.css"; From dae4f7b3cc55a0e0c82c34ffdaa365732c82dad0 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 19 Feb 2025 13:00:07 -0600 Subject: [PATCH 12/25] [PM-17564] Prompt Browser Extension (#13349) * add browser extension prompt page with initial loading state * add browser extension icon * move browser extension prompt to state * add installation link for error state * automatically open extension when possible for browser-reprompt-page * refactor browser tabs query into a standalone method * add success message state for auto-opening browsers * Refactor `VaultOnboardingMessages` to `VaultMessages` to be more generic * add auto-open extension messages to `VaultMessages` enum * add bitwarden icon * Add manual error state for firefox users * add extension prompt routing * fix incorrect imports * add mobile screen for browser prompt * remove comment * fix typo in code comment * update key for `checkBwInstalled` method * add check for safari before attempting to send a message * break translation for manual opening into two parts --- .../content/content-message-handler.spec.ts | 6 +- .../content/content-message-handler.ts | 11 +- .../browser/src/background/main.background.ts | 11 +- .../src/background/runtime.background.ts | 39 +++- .../content/send-on-installed-message.ts | 4 +- .../vault/content/send-popup-open-message.ts | 6 + apps/browser/webpack.config.js | 1 + apps/web/src/app/oss-routing.module.ts | 19 ++ ...er-extension-prompt-install.component.html | 4 + ...extension-prompt-install.component.spec.ts | 145 +++++++++++++++ ...wser-extension-prompt-install.component.ts | 66 +++++++ .../browser-extension-prompt.component.html | 44 +++++ ...browser-extension-prompt.component.spec.ts | 104 +++++++++++ .../browser-extension-prompt.component.ts | 37 ++++ .../vault-onboarding.component.spec.ts | 8 +- .../vault-onboarding.component.ts | 6 +- .../browser-extension-prompt.service.spec.ts | 173 ++++++++++++++++++ .../browser-extension-prompt.service.ts | 125 +++++++++++++ apps/web/src/locales/en/messages.json | 39 +++- .../src/vault/enums/vault-messages.enum.ts | 8 + .../src/vault/enums/vault-onboarding.enum.ts | 6 - libs/vault/src/icons/bitwarden-icon.ts | 20 ++ libs/vault/src/icons/browser-extension.ts | 17 ++ libs/vault/src/icons/index.ts | 2 + 24 files changed, 869 insertions(+), 32 deletions(-) create mode 100644 apps/browser/src/vault/content/send-popup-open-message.ts create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts create mode 100644 apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts create mode 100644 apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts create mode 100644 apps/web/src/app/vault/services/browser-extension-prompt.service.ts create mode 100644 libs/common/src/vault/enums/vault-messages.enum.ts delete mode 100644 libs/common/src/vault/enums/vault-onboarding.enum.ts create mode 100644 libs/vault/src/icons/bitwarden-icon.ts create mode 100644 libs/vault/src/icons/browser-extension.ts 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 a37a2e07678..99d0d9031cf 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -1,6 +1,6 @@ import { mock } from "jest-mock-extended"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils"; @@ -34,10 +34,10 @@ describe("ContentMessageHandler", () => { const mockPostMessage = jest.fn(); window.postMessage = mockPostMessage; - postWindowMessage({ command: VaultOnboardingMessages.checkBwInstalled }); + postWindowMessage({ command: VaultMessages.checkBwInstalled }); expect(mockPostMessage).toHaveBeenCalledWith({ - command: VaultOnboardingMessages.HasBwInstalled, + command: VaultMessages.HasBwInstalled, }); }); }); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index ef542896492..5f98cf348a3 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -1,4 +1,4 @@ -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { ContentMessageWindowData, @@ -26,16 +26,17 @@ const windowMessageHandlers: ContentMessageWindowEventHandlers = { handleAuthResultMessage(data, referrer), webAuthnResult: ({ data, referrer }: { data: any; referrer: string }) => handleWebAuthnResultMessage(data, referrer), - checkIfBWExtensionInstalled: () => handleExtensionInstallCheck(), + [VaultMessages.checkBwInstalled]: () => handleExtensionInstallCheck(), duoResult: ({ data, referrer }: { data: any; referrer: string }) => handleDuoResultMessage(data, referrer), + [VaultMessages.OpenPopup]: () => handleOpenPopupMessage(), }; /** * Handles the post to the web vault showing the extension has been installed */ function handleExtensionInstallCheck() { - window.postMessage({ command: VaultOnboardingMessages.HasBwInstalled }); + window.postMessage({ command: VaultMessages.HasBwInstalled }); } /** @@ -71,6 +72,10 @@ function handleWebAuthnResultMessage(data: ContentMessageWindowData, referrer: s sendExtensionRuntimeMessage({ command, data: data.data, remember, referrer }); } +function handleOpenPopupMessage() { + sendExtensionRuntimeMessage({ command: VaultMessages.OpenPopup }); +} + /** * Handles the window message event. * diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1c6d018a82c..8faaec4f023 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1592,13 +1592,16 @@ export default class MainBackground { } async openPopup() { - // Chrome APIs cannot open popup + const browserAction = BrowserApi.getBrowserAction(); - // TODO: Do we need to open this popup? - if (!this.isSafari) { + if ("openPopup" in browserAction && typeof browserAction.openPopup === "function") { + await browserAction.openPopup(); return; } - await SafariApp.sendMessageToApp("showPopover", null, true); + + if (this.isSafari) { + await SafariApp.sendMessageToApp("showPopover", null, true); + } } async reseedStorage() { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2a756293070..a5fea0651fc 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -289,7 +289,7 @@ export default class RuntimeBackground { } break; case "openPopup": - await this.main.openPopup(); + await this.openPopup(); break; case "bgUpdateContextMenu": case "editedCipher": @@ -405,13 +405,40 @@ export default class RuntimeBackground { }, 100); } + /** Returns the browser tabs that have the web vault open */ + private async getBwTabs() { + const env = await firstValueFrom(this.environmentService.environment$); + const vaultUrl = env.getWebVaultUrl(); + const urlObj = new URL(vaultUrl); + + return await BrowserApi.tabsQuery({ url: `${urlObj.href}*` }); + } + + private async openPopup() { + await this.main.openPopup(); + + const announcePopupOpen = async () => { + const isOpen = await this.platformUtilsService.isViewOpen(); + const tabs = await this.getBwTabs(); + + if (isOpen && tabs.length > 0) { + // Send message to all vault tabs that the extension has opened + for (const tab of tabs) { + await BrowserApi.executeScriptInTab(tab.id, { + file: "content/send-popup-open-message.js", + runAt: "document_end", + }); + } + } + }; + + // Give the popup a buffer to open + setTimeout(announcePopupOpen, 100); + } + async sendBwInstalledMessageToVault() { try { - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - const urlObj = new URL(vaultUrl); - - const tabs = await BrowserApi.tabsQuery({ url: `${urlObj.href}*` }); + const tabs = await this.getBwTabs(); if (!tabs?.length) { return; diff --git a/apps/browser/src/vault/content/send-on-installed-message.ts b/apps/browser/src/vault/content/send-on-installed-message.ts index 9df15eb0d51..8da9e250249 100644 --- a/apps/browser/src/vault/content/send-on-installed-message.ts +++ b/apps/browser/src/vault/content/send-on-installed-message.ts @@ -1,5 +1,5 @@ -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; (function (globalContext) { - globalContext.postMessage({ command: VaultOnboardingMessages.HasBwInstalled }); + globalContext.postMessage({ command: VaultMessages.HasBwInstalled }); })(window); diff --git a/apps/browser/src/vault/content/send-popup-open-message.ts b/apps/browser/src/vault/content/send-popup-open-message.ts new file mode 100644 index 00000000000..6889d24f7f6 --- /dev/null +++ b/apps/browser/src/vault/content/send-popup-open-message.ts @@ -0,0 +1,6 @@ +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +(function (globalContext) { + // Send a message to the window that the popup opened + globalContext.postMessage({ command: VaultMessages.PopupOpened }); +})(window); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index b6e8a147a50..a5fe6ed94fd 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -207,6 +207,7 @@ const mainConfig = { "./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts", "encrypt-worker": "../../libs/common/src/key-management/crypto/services/encrypt.worker.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", }, optimization: { minimize: ENV !== "development", diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 5f2b839ae97..554f1f62e24 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -92,6 +92,8 @@ import { CredentialGeneratorComponent } from "./tools/credential-generator/crede import { ReportsModule } from "./tools/reports"; import { AccessComponent, SendAccessExplainerComponent } from "./tools/send/send-access"; import { SendComponent } from "./tools/send/send.component"; +import { BrowserExtensionPromptInstallComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt-install.component"; +import { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component"; import { VaultModule } from "./vault/individual-vault/vault.module"; const routes: Routes = [ @@ -695,6 +697,23 @@ const routes: Routes = [ maxWidth: "3xl", } satisfies AnonLayoutWrapperData, }, + { + path: "browser-extension-prompt", + data: { + pageIcon: VaultIcons.BrowserExtensionIcon, + } satisfies AnonLayoutWrapperData, + children: [ + { + path: "", + component: BrowserExtensionPromptComponent, + }, + { + path: "", + component: BrowserExtensionPromptInstallComponent, + outlet: "secondary", + }, + ], + }, ], }, { diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html new file mode 100644 index 00000000000..709f4e8993e --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html @@ -0,0 +1,4 @@ +
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts new file mode 100644 index 00000000000..e3729130a01 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { DeviceType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +import { BrowserExtensionPromptInstallComponent } from "./browser-extension-prompt-install.component"; + +describe("BrowserExtensionInstallComponent", () => { + let fixture: ComponentFixture; + let component: BrowserExtensionPromptInstallComponent; + const pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + const getDevice = jest.fn(); + + beforeEach(async () => { + getDevice.mockClear(); + await TestBed.configureTestingModule({ + providers: [ + { + provide: BrowserExtensionPromptService, + useValue: { pageState$ }, + }, + { + provide: I18nService, + useValue: { t: (key: string) => key }, + }, + { + provide: PlatformUtilsService, + useValue: { getDevice }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BrowserExtensionPromptInstallComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("only shows during error state", () => { + expect(fixture.nativeElement.textContent).toBe(""); + + pageState$.next(BrowserPromptState.Success); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe(""); + + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toBe(""); + + pageState$.next(BrowserPromptState.ManualOpen); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toBe(""); + }); + + describe("error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + }); + + it("shows error text", () => { + const errorText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(errorText.textContent).toBe("doNotHaveExtension"); + }); + + it("links to bitwarden installation page by default", () => { + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://bitwarden.com/download/#downloads-web-browser", + ); + }); + + it("links to bitwarden installation page for Chrome", () => { + getDevice.mockReturnValue(DeviceType.ChromeBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://chrome.google.com/webstore/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb", + ); + }); + + it("links to bitwarden installation page for Firefox", () => { + getDevice.mockReturnValue(DeviceType.FirefoxBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/", + ); + }); + + it("links to bitwarden installation page for Safari", () => { + getDevice.mockReturnValue(DeviceType.SafariBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12", + ); + }); + + it("links to bitwarden installation page for Opera", () => { + getDevice.mockReturnValue(DeviceType.OperaBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/", + ); + }); + + it("links to bitwarden installation page for Edge", () => { + getDevice.mockReturnValue(DeviceType.EdgeBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh", + ); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts new file mode 100644 index 00000000000..73f4307d9cc --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { map } from "rxjs"; + +import { DeviceType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LinkModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +/** Device specific Urls for the extension */ +const WebStoreUrls: Partial> = { + [DeviceType.ChromeBrowser]: + "https://chrome.google.com/webstore/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb", + [DeviceType.FirefoxBrowser]: + "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/", + [DeviceType.SafariBrowser]: "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12", + [DeviceType.OperaBrowser]: + "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/", + [DeviceType.EdgeBrowser]: + "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh", +}; + +@Component({ + selector: "vault-browser-extension-prompt-install", + templateUrl: "./browser-extension-prompt-install.component.html", + standalone: true, + imports: [CommonModule, I18nPipe, LinkModule], +}) +export class BrowserExtensionPromptInstallComponent implements OnInit { + /** The install link should only show for the error states */ + protected shouldShow$ = this.browserExtensionPromptService.pageState$.pipe( + map((state) => state === BrowserPromptState.Error || state === BrowserPromptState.ManualOpen), + ); + + /** All available page states */ + protected BrowserPromptState = BrowserPromptState; + + /** + * Installation link for the extension + */ + protected webStoreUrl: string = "https://bitwarden.com/download/#downloads-web-browser"; + + constructor( + private browserExtensionPromptService: BrowserExtensionPromptService, + private platformService: PlatformUtilsService, + ) {} + + ngOnInit(): void { + this.setBrowserStoreLink(); + } + + /** If available, set web store specific URL for the extension */ + private setBrowserStoreLink(): void { + const deviceType = this.platformService.getDevice(); + const platformSpecificUrl = WebStoreUrls[deviceType]; + + if (platformSpecificUrl) { + this.webStoreUrl = platformSpecificUrl; + } + } +} diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html new file mode 100644 index 00000000000..1c643fcc3e4 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html @@ -0,0 +1,44 @@ +
+ + +

{{ "openingExtension" | i18n }}

+
+ + +

{{ "openingExtensionError" | i18n }}

+ +
+ + + +

+ {{ "openedExtensionViewAtRiskPasswords" | i18n }} +

+
+ + +

+ {{ "openExtensionManuallyPart1" | i18n }} + + {{ "openExtensionManuallyPart2" | i18n }} +

+
+ + +

+ {{ "reopenLinkOnDesktop" | i18n }} +

+
+
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts new file mode 100644 index 00000000000..40dbc0d442e --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts @@ -0,0 +1,104 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +import { BrowserExtensionPromptComponent } from "./browser-extension-prompt.component"; + +describe("BrowserExtensionPromptComponent", () => { + let fixture: ComponentFixture; + + const start = jest.fn(); + const pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + beforeEach(async () => { + start.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { + provide: BrowserExtensionPromptService, + useValue: { start, pageState$ }, + }, + { + provide: I18nService, + useValue: { t: (key: string) => key }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BrowserExtensionPromptComponent); + fixture.detectChanges(); + }); + + it("calls start on initialization", () => { + expect(start).toHaveBeenCalledTimes(1); + }); + + describe("loading state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Loading); + fixture.detectChanges(); + }); + + it("shows loading text", () => { + const element = fixture.nativeElement; + expect(element.textContent.trim()).toBe("openingExtension"); + }); + }); + + describe("error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + }); + + it("shows error text", () => { + const errorText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(errorText.textContent.trim()).toBe("openingExtensionError"); + }); + }); + + describe("success state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Success); + fixture.detectChanges(); + }); + + it("shows success message", () => { + const successText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(successText.textContent.trim()).toBe("openedExtensionViewAtRiskPasswords"); + }); + }); + + describe("mobile state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.MobileBrowser); + fixture.detectChanges(); + }); + + it("shows mobile message", () => { + const mobileText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(mobileText.textContent.trim()).toBe("reopenLinkOnDesktop"); + }); + }); + + describe("manual error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.ManualOpen); + fixture.detectChanges(); + }); + + it("shows manual open error message", () => { + const manualText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(manualText.textContent.trim()).toContain("openExtensionManuallyPart1"); + expect(manualText.textContent.trim()).toContain("openExtensionManuallyPart2"); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts new file mode 100644 index 00000000000..640a1b0d771 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; + +import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultIcons } from "@bitwarden/vault"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +@Component({ + selector: "vault-browser-extension-prompt", + templateUrl: "./browser-extension-prompt.component.html", + standalone: true, + imports: [CommonModule, I18nPipe, ButtonComponent, IconModule], +}) +export class BrowserExtensionPromptComponent implements OnInit { + /** Current state of the prompt page */ + protected pageState$ = this.browserExtensionPromptService.pageState$; + + /** All available page states */ + protected BrowserPromptState = BrowserPromptState; + + protected BitwardenIcon = VaultIcons.BitwardenIcon; + + constructor(private browserExtensionPromptService: BrowserExtensionPromptService) {} + + ngOnInit(): void { + this.browserExtensionPromptService.start(); + } + + openExtension(): void { + this.browserExtensionPromptService.openExtension(); + } +} 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 62abc0c0b34..1a767bc8964 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 @@ -16,7 +16,7 @@ import { StateProvider } from "@bitwarden/common/platform/state"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service"; import { VaultOnboardingComponent } from "./vault-onboarding.component"; @@ -158,7 +158,7 @@ describe("VaultOnboardingComponent", () => { it("should call getMessages when showOnboarding is true", () => { const messageEventSubject = new Subject(); const messageEvent = new MessageEvent("message", { - data: VaultOnboardingMessages.HasBwInstalled, + data: VaultMessages.HasBwInstalled, }); const getMessagesSpy = jest.spyOn(component, "getMessages"); @@ -168,7 +168,7 @@ describe("VaultOnboardingComponent", () => { void fixture.whenStable().then(() => { expect(window.postMessage).toHaveBeenCalledWith({ - command: VaultOnboardingMessages.checkBwInstalled, + command: VaultMessages.checkBwInstalled, }); expect(getMessagesSpy).toHaveBeenCalled(); }); @@ -188,7 +188,7 @@ describe("VaultOnboardingComponent", () => { installExtension: false, }); }); - const eventData = { data: { command: VaultOnboardingMessages.HasBwInstalled } }; + const eventData = { data: { command: VaultMessages.HasBwInstalled } }; (component as any).showOnboarding = true; 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 4b69e3977c6..dc4a014073a 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 @@ -24,7 +24,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LinkModule } from "@bitwarden/components"; @@ -106,12 +106,12 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { void this.getMessages(event); }); - window.postMessage({ command: VaultOnboardingMessages.checkBwInstalled }); + window.postMessage({ command: VaultMessages.checkBwInstalled }); } } async getMessages(event: any) { - if (event.data.command === VaultOnboardingMessages.HasBwInstalled && this.showOnboarding) { + if (event.data.command === VaultMessages.HasBwInstalled && this.showOnboarding) { const currentTasks = await firstValueFrom(this.onboardingTasks$); const updatedTasks = { createAccount: currentTasks.createAccount, diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts new file mode 100644 index 00000000000..647af007eca --- /dev/null +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts @@ -0,0 +1,173 @@ +import { TestBed } from "@angular/core/testing"; + +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "./browser-extension-prompt.service"; + +describe("BrowserExtensionPromptService", () => { + let service: BrowserExtensionPromptService; + const setAnonLayoutWrapperData = jest.fn(); + const isFirefox = jest.fn().mockReturnValue(false); + const postMessage = jest.fn(); + window.postMessage = postMessage; + + beforeEach(() => { + setAnonLayoutWrapperData.mockClear(); + postMessage.mockClear(); + isFirefox.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + BrowserExtensionPromptService, + { provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } }, + { provide: PlatformUtilsService, useValue: { isFirefox } }, + ], + }); + jest.useFakeTimers(); + service = TestBed.inject(BrowserExtensionPromptService); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it("defaults page state to loading", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Loading); + done(); + }); + }); + + describe("start", () => { + it("posts message to check for extension", () => { + service.start(); + + expect(window.postMessage).toHaveBeenCalledWith({ + command: VaultMessages.checkBwInstalled, + }); + }); + + it("sets timeout for error state", () => { + service.start(); + + expect(service["extensionCheckTimeout"]).not.toBeNull(); + }); + + it("attempts to open the extension when installed", () => { + service.start(); + + window.dispatchEvent( + new MessageEvent("message", { data: { command: VaultMessages.HasBwInstalled } }), + ); + + expect(window.postMessage).toHaveBeenCalledTimes(2); + expect(window.postMessage).toHaveBeenCalledWith({ command: VaultMessages.OpenPopup }); + }); + }); + + describe("success state", () => { + beforeEach(() => { + service.start(); + + window.dispatchEvent( + new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }), + ); + }); + + it("sets layout title", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "openedExtension" }, + }); + }); + + it("sets success page state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Success); + done(); + }); + }); + + it("clears the error timeout", () => { + expect(service["extensionCheckTimeout"]).toBeUndefined(); + }); + }); + + describe("firefox", () => { + beforeEach(() => { + isFirefox.mockReturnValue(true); + service.start(); + }); + + afterEach(() => { + isFirefox.mockReturnValue(false); + }); + + it("sets manual open state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.ManualOpen); + done(); + }); + }); + + it("sets error state after timeout", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "somethingWentWrong" }, + }); + }); + }); + + describe("mobile state", () => { + beforeEach(() => { + Utils.isMobileBrowser = true; + service.start(); + }); + + afterEach(() => { + Utils.isMobileBrowser = false; + }); + + it("sets mobile state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.MobileBrowser); + done(); + }); + }); + + it("sets desktop required title", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "desktopRequired" }, + }); + }); + + it("clears the error timeout", () => { + expect(service["extensionCheckTimeout"]).toBeUndefined(); + }); + }); + + describe("error state", () => { + beforeEach(() => { + service.start(); + jest.advanceTimersByTime(1000); + }); + + it("sets error state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Error); + done(); + }); + }); + + it("sets error state after timeout", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "somethingWentWrong" }, + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts new file mode 100644 index 00000000000..fec27758d3c --- /dev/null +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts @@ -0,0 +1,125 @@ +import { DestroyRef, Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, fromEvent } from "rxjs"; + +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +export enum BrowserPromptState { + Loading = "loading", + Error = "error", + Success = "success", + ManualOpen = "manualOpen", + MobileBrowser = "mobileBrowser", +} + +type PromptErrorStates = BrowserPromptState.Error | BrowserPromptState.ManualOpen; + +@Injectable({ + providedIn: "root", +}) +export class BrowserExtensionPromptService { + private _pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + /** Current state of the prompt page */ + pageState$ = this._pageState$.asObservable(); + + /** Timeout identifier for extension check */ + private extensionCheckTimeout: number | undefined; + + constructor( + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private destroyRef: DestroyRef, + private platformUtilsService: PlatformUtilsService, + ) {} + + start(): void { + if (Utils.isMobileBrowser) { + this.setMobileState(); + return; + } + + // Firefox does not support automatically opening the extension, + // it currently requires a user gesture within the context of the extension to open. + // Show message to direct the user to manually open the extension. + // Mozilla Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1799344 + if (this.platformUtilsService.isFirefox()) { + this.setErrorState(BrowserPromptState.ManualOpen); + return; + } + + this.checkForBrowserExtension(); + } + + /** Post a message to the extension to open */ + openExtension() { + window.postMessage({ command: VaultMessages.OpenPopup }); + } + + /** Send message checking for the browser extension */ + private checkForBrowserExtension() { + fromEvent(window, "message") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + void this.getMessages(event); + }); + + window.postMessage({ command: VaultMessages.checkBwInstalled }); + + // Wait a second for the extension to respond and open, else show the error state + this.extensionCheckTimeout = window.setTimeout(() => { + this.setErrorState(); + }, 1000); + } + + /** Handle window message events */ + private getMessages(event: any) { + if (event.data.command === VaultMessages.HasBwInstalled) { + this.openExtension(); + } + + if (event.data.command === VaultMessages.PopupOpened) { + this.setSuccessState(); + } + } + + /** Show message that this page should be opened on a desktop browser */ + private setMobileState() { + this.clearExtensionCheckTimeout(); + this._pageState$.next(BrowserPromptState.MobileBrowser); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "desktopRequired", + }, + }); + } + + /** Show the open extension success state */ + private setSuccessState() { + this.clearExtensionCheckTimeout(); + this._pageState$.next(BrowserPromptState.Success); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "openedExtension", + }, + }); + } + + /** Show open extension error state */ + private setErrorState(errorState?: PromptErrorStates) { + this.clearExtensionCheckTimeout(); + this._pageState$.next(errorState ?? BrowserPromptState.Error); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "somethingWentWrong", + }, + }); + } + + private clearExtensionCheckTimeout() { + window.clearTimeout(this.extensionCheckTimeout); + this.extensionCheckTimeout = undefined; + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c241003e7a..2ae1b8905ab 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9275,7 +9275,12 @@ }, "deviceManagementDesc":{ "message": "Configure device management for Bitwarden using the implementation guide for your platform." - + }, + "desktopRequired": { + "message": "Desktop required" + }, + "reopenLinkOnDesktop": { + "message": "Reopen this link from your email on a desktop." }, "integrationCardTooltip":{ "message": "Launch $INTEGRATION$ implementation guide.", @@ -10270,6 +10275,38 @@ "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." }, + "openingExtension": { + "message": "Opening the Bitwarden browser extension" + }, + "somethingWentWrong":{ + "message": "Something went wrong..." + }, + "openingExtensionError": { + "message": "We had trouble opening the Bitwarden browser extension. Click the button to open it now." + }, + "openExtension": { + "message": "Open extension" + }, + "doNotHaveExtension": { + "message": "Don't have the Bitwarden browser extension?" + }, + "installExtension": { + "message": "Install extension" + }, + "openedExtension": { + "message": "Opened the browser extension" + }, + "openedExtensionViewAtRiskPasswords": { + "message": "Successfully opened the Bitwarden browser extension. You can now review your at-risk passwords." + }, + "openExtensionManuallyPart1": { + "message": "We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon", + "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'" + }, + "openExtensionManuallyPart2": { + "message": "from the toolbar.", + "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'" + }, "resellerRenewalWarningMsg": { "message": "Your subscription will renew soon. To ensure uninterrupted service, contact $RESELLER$ to confirm your renewal before $RENEWAL_DATE$.", "placeholders": { diff --git a/libs/common/src/vault/enums/vault-messages.enum.ts b/libs/common/src/vault/enums/vault-messages.enum.ts new file mode 100644 index 00000000000..4cc038be849 --- /dev/null +++ b/libs/common/src/vault/enums/vault-messages.enum.ts @@ -0,0 +1,8 @@ +const VaultMessages = { + HasBwInstalled: "hasBwInstalled", + checkBwInstalled: "checkIfBWExtensionInstalled", + OpenPopup: "openPopup", + PopupOpened: "popupOpened", +} as const; + +export { VaultMessages }; diff --git a/libs/common/src/vault/enums/vault-onboarding.enum.ts b/libs/common/src/vault/enums/vault-onboarding.enum.ts deleted file mode 100644 index 11e072b3284..00000000000 --- a/libs/common/src/vault/enums/vault-onboarding.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -const VaultOnboardingMessages = { - HasBwInstalled: "hasBwInstalled", - checkBwInstalled: "checkIfBWExtensionInstalled", -} as const; - -export { VaultOnboardingMessages }; diff --git a/libs/vault/src/icons/bitwarden-icon.ts b/libs/vault/src/icons/bitwarden-icon.ts new file mode 100644 index 00000000000..73e4304d5c7 --- /dev/null +++ b/libs/vault/src/icons/bitwarden-icon.ts @@ -0,0 +1,20 @@ +import { svgIcon } from "@bitwarden/components"; + +export const BitwardenIcon = svgIcon` + + + + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/icons/browser-extension.ts b/libs/vault/src/icons/browser-extension.ts new file mode 100644 index 00000000000..f0f9b781491 --- /dev/null +++ b/libs/vault/src/icons/browser-extension.ts @@ -0,0 +1,17 @@ +import { svgIcon } from "@bitwarden/components"; + +export const BrowserExtensionIcon = svgIcon` + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/icons/index.ts b/libs/vault/src/icons/index.ts index 2e106782f53..e0be5e637f0 100644 --- a/libs/vault/src/icons/index.ts +++ b/libs/vault/src/icons/index.ts @@ -4,3 +4,5 @@ export * from "./vault"; export * from "./empty-trash"; export * from "./exclamation-triangle"; export * from "./user-lock"; +export * from "./browser-extension"; +export * from "./bitwarden-icon"; From 04f5fe4da401fe15295a13d99bba5311b08f6bc6 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:29:12 -0500 Subject: [PATCH 13/25] fix(AnonLayout): [PM-18408] - Extension - add responsive scaling to avoid scrollbars on login in new UI (#13475) --- .../src/angular/anon-layout/anon-layout.component.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index cb3445abd96..4120ea59002 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -1,5 +1,5 @@
-
+
@@ -40,14 +40,14 @@ [ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }" >
-