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/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7eec2804ece..650ca6a5640 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -176,6 +176,10 @@ "copyNotes": { "message": "Copy notes" }, + "copy": { + "message": "Copy", + "description": "Copy to clipboard" + }, "fill":{ "message": "Fill", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." @@ -2345,6 +2349,9 @@ "blockedDomains": { "message": "Blocked domains" }, + "learnMoreAboutBlockedDomains": { + "message": "Learn more about blocked domains" + }, "excludedDomains": { "message": "Excluded domains" }, 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/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; +`; 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/autofill/popup/settings/blocked-domains.component.html b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html index bf5f40f2b90..302b14247e9 100644 --- a/apps/browser/src/autofill/popup/settings/blocked-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html @@ -6,50 +6,64 @@
-

{{ "blockedDomainsDesc" | i18n }}

+ +

{{ "blockedDomainsDesc" | i18n }}

+ {{ "learnMoreAboutBlockedDomains" | i18n }} +

{{ "domainsTitle" | i18n }}

- {{ blockedDomainsState?.length || 0 }} + {{ + blockedDomainsState.length + domainForms.value.length + }}
- - - + +
{{ domain }}
+
+ +
+
+ - - {{ - "websiteItemLabel" | i18n: i + 1 - }} + + {{ "websiteItemLabel" | i18n: i + fieldsEditThreshold + 1 }} -
{{ domain }}
-
- - - - + +
+ +
diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts index 461f62da6dc..16e4b6f6751 100644 --- a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts @@ -7,7 +7,13 @@ import { AfterViewInit, ViewChildren, } from "@angular/core"; -import { FormsModule } from "@angular/forms"; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + FormGroup, + FormArray, +} from "@angular/forms"; import { RouterModule } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; @@ -44,6 +50,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co CommonModule, FormFieldModule, FormsModule, + ReactiveFormsModule, IconButtonModule, ItemModule, JslibModule, @@ -66,6 +73,11 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { isLoading = false; blockedDomainsState: string[] = []; storedBlockedDomains: string[] = []; + + protected domainListForm = new FormGroup({ + domains: this.formBuilder.array([]), + }); + // How many fields should be non-editable before editable fields fieldsEditThreshold: number = 0; @@ -75,8 +87,13 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { private domainSettingsService: DomainSettingsService, private i18nService: I18nService, private toastService: ToastService, + private formBuilder: FormBuilder, ) {} + get domainForms() { + return this.domainListForm.get("domains") as FormArray; + } + async ngAfterViewInit() { this.domainSettingsService.blockedInteractionsUris$ .pipe(takeUntil(this.destroy$)) @@ -113,8 +130,7 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { } async addNewDomain() { - // add empty field to the Domains list interface - this.blockedDomainsState.push(""); + this.domainForms.push(this.formBuilder.control(null)); await this.fieldChange(); } @@ -144,7 +160,8 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { this.isLoading = true; const newBlockedDomainsSaveState: NeverDomains = {}; - const uniqueBlockedDomains = new Set(this.blockedDomainsState); + + const uniqueBlockedDomains = new Set([...this.blockedDomainsState, ...this.domainForms.value]); for (const uri of uniqueBlockedDomains) { if (uri && uri !== "") { @@ -152,7 +169,7 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { if (!validatedHost) { this.toastService.showToast({ - message: this.i18nService.t("excludedDomainsInvalidDomain", uri), + message: this.i18nService.t("blockedDomainsInvalidDomain", uri), title: "", variant: "error", }); @@ -190,13 +207,14 @@ export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { title: "", variant: "success", }); + + this.domainForms.clear(); } catch { this.toastService.showToast({ message: this.i18nService.t("unexpectedError"), title: "", variant: "error", }); - // Don't reset via `handleStateUpdate` to preserve input values this.isLoading = false; } diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index e3b6bf5f802..92abef3730c 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -14,46 +14,52 @@

{{ "domainsTitle" | i18n }}

- {{ excludedDomainsState?.length || 0 }} + {{ + excludedDomainsState.length + domainForms.value.length + }}
- - - + +
{{ domain }}
+
+ +
+
+ - - {{ - "websiteItemLabel" | i18n: i + 1 - }} + + {{ "websiteItemLabel" | i18n: i + fieldsEditThreshold + 1 }} -
{{ domain }}
-
- - - - + +
+ +
diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 7d429bfe4f0..0447d8076c2 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -7,7 +7,13 @@ import { AfterViewInit, ViewChildren, } from "@angular/core"; -import { FormsModule } from "@angular/forms"; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + FormGroup, + FormArray, +} from "@angular/forms"; import { RouterModule } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; @@ -45,6 +51,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co CommonModule, FormFieldModule, FormsModule, + ReactiveFormsModule, IconButtonModule, ItemModule, JslibModule, @@ -68,6 +75,11 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { isLoading = false; excludedDomainsState: string[] = []; storedExcludedDomains: string[] = []; + + protected domainListForm = new FormGroup({ + domains: this.formBuilder.array([]), + }); + // How many fields should be non-editable before editable fields fieldsEditThreshold: number = 0; @@ -77,10 +89,15 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private domainSettingsService: DomainSettingsService, private i18nService: I18nService, private toastService: ToastService, + private formBuilder: FormBuilder, ) { this.accountSwitcherEnabled = enableAccountSwitching(); } + get domainForms() { + return this.domainListForm.get("domains") as FormArray; + } + async ngAfterViewInit() { this.domainSettingsService.neverDomains$ .pipe(takeUntil(this.destroy$)) @@ -117,8 +134,7 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { } async addNewDomain() { - // add empty field to the Domains list interface - this.excludedDomainsState.push(""); + this.domainForms.push(this.formBuilder.control(null)); await this.fieldChange(); } @@ -148,7 +164,11 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { this.isLoading = true; const newExcludedDomainsSaveState: NeverDomains = {}; - const uniqueExcludedDomains = new Set(this.excludedDomainsState); + + const uniqueExcludedDomains = new Set([ + ...this.excludedDomainsState, + ...this.domainForms.value, + ]); for (const uri of uniqueExcludedDomains) { if (uri && uri !== "") { @@ -194,13 +214,14 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { title: "", variant: "success", }); + + this.domainForms.clear(); } catch { this.toastService.showToast({ message: this.i18nService.t("unexpectedError"), title: "", variant: "error", }); - // Don't reset via `handleStateUpdate` to preserve input values this.isLoading = false; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 8a77534d0d4..48bc7ceafda 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -459,15 +459,20 @@ describe("AutofillOverlayContentService", () => { const passwordFieldElement = document.getElementById( "password-field", ) as ElementWithOpId; - autofillFieldData.type = "password"; + + const passwordFieldData = createAutofillFieldMock({ + opid: "password-field", + form: "validFormId", + elementNumber: 2, + type: "password", + }); await autofillOverlayContentService.setupOverlayListeners( passwordFieldElement, - autofillFieldData, + passwordFieldData, pageDetailsMock, ); passwordFieldElement.dispatchEvent(new Event("input")); - expect(autofillOverlayContentService["userFilledFields"].password).toEqual( passwordFieldElement, ); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d2b51c7ef40..776f0570249 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -607,6 +607,7 @@ export default class MainBackground { this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, this.globalStateProvider, + this.taskSchedulerService, ); const migrationRunner = new MigrationRunner( @@ -1374,17 +1375,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(); } @@ -1568,13 +1593,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/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/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts index e739a3677f1..ba5d4b73f72 100644 --- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts +++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts @@ -3,6 +3,11 @@ import { switchMap, merge, delay, filter, concatMap, map, first, of } from "rxjs"; import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging"; +import { + ScheduledTaskNames, + TaskSchedulerService, + toScheduler, +} from "@bitwarden/common/platform/scheduling"; import { POPUP_VIEW_MEMORY, KeyDefinition, @@ -45,7 +50,15 @@ export class PopupViewCacheBackgroundService { constructor( private messageListener: MessageListener, private globalStateProvider: GlobalStateProvider, - ) {} + private readonly taskSchedulerService: TaskSchedulerService, + ) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.clearPopupViewCache, + async () => { + await this.clearState(); + }, + ); + } startObservingTabChanges() { this.messageListener @@ -87,7 +100,14 @@ export class PopupViewCacheBackgroundService { // on popup closed, with 2 minute delay that is cancelled by re-opening the popup fromChromeEvent(chrome.runtime.onConnect).pipe( filter(([port]) => port.name === popupClosedPortName), - switchMap(([port]) => fromChromeEvent(port.onDisconnect).pipe(delay(1000 * 60 * 2))), + switchMap(([port]) => + fromChromeEvent(port.onDisconnect).pipe( + delay( + 1000 * 60 * 2, + toScheduler(this.taskSchedulerService, ScheduledTaskNames.clearPopupViewCache), + ), + ), + ), ), ) .pipe(switchMap(() => this.clearState())) 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/about-dialog/about-dialog.component.ts b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts index f730fef24b3..1e5bdee09b9 100644 --- a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts +++ b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts @@ -1,20 +1,24 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, ElementRef, ViewChild } from "@angular/core"; import { Observable, combineLatest, defer, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { ButtonModule, DialogModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; @Component({ templateUrl: "about-dialog.component.html", standalone: true, - imports: [CommonModule, JslibModule, DialogModule, ButtonModule], + imports: [CommonModule, JslibModule, DialogModule, ButtonModule, TypographyModule], }) export class AboutDialogComponent { + @ViewChild("version") protected version!: ElementRef; + protected year = new Date().getFullYear(); protected version$: Observable; @@ -26,11 +30,23 @@ export class AboutDialogComponent { protected sdkVersion$ = this.sdkService.version$; constructor( + private dialogRef: DialogRef, private configService: ConfigService, private environmentService: EnvironmentService, private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + private i18nService: I18nService, private sdkService: SdkService, ) { this.version$ = defer(() => this.platformUtilsService.getApplicationVersion()); } + + protected copyVersion() { + this.platformUtilsService.copyToClipboard(this.version.nativeElement.innerText); + this.toastService.showToast({ + message: this.i18nService.t("copySuccessful"), + variant: "success", + }); + this.dialogRef.close(); + } } diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html index 9ad6ed36835..8493fa5fee7 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html @@ -23,5 +23,8 @@ > {{ "exportVault" | i18n }} + 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/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 3f0030e2c53..ff5331ae459 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -219,6 +219,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/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/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index d6c7bdd97cd..2de5b83c40a 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -6,11 +6,13 @@ import { RouterModule, Routes } from "@angular/router"; import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { ExposedPasswordsReportComponent } from "../../../admin-console/organizations/tools/exposed-passwords-report.component"; -import { InactiveTwoFactorReportComponent } from "../../../admin-console/organizations/tools/inactive-two-factor-report.component"; -import { ReusedPasswordsReportComponent } from "../../../admin-console/organizations/tools/reused-passwords-report.component"; -import { UnsecuredWebsitesReportComponent } from "../../../admin-console/organizations/tools/unsecured-websites-report.component"; -import { WeakPasswordsReportComponent } from "../../../admin-console/organizations/tools/weak-passwords-report.component"; +/* eslint no-restricted-imports: "off" -- Normally prohibited by Tools Team eslint rules but required here */ +import { ExposedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/exposed-passwords-report.component"; +import { InactiveTwoFactorReportComponent } from "../../../tools/reports/pages/organizations/inactive-two-factor-report.component"; +import { ReusedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/reused-passwords-report.component"; +import { UnsecuredWebsitesReportComponent } from "../../../tools/reports/pages/organizations/unsecured-websites-report.component"; +import { WeakPasswordsReportComponent } from "../../../tools/reports/pages/organizations/weak-passwords-report.component"; +/* eslint no-restricted-imports: "error" */ import { isPaidOrgGuard } from "../guards/is-paid-org.guard"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { organizationRedirectGuard } from "../guards/org-redirect.guard"; diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index 06ceaa0d9c7..a644086628c 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -52,7 +52,9 @@ const routes: Routes = [ { path: "import", loadComponent: () => - import("./org-import.component").then((mod) => mod.OrgImportComponent), + import("../../../tools/import/org-import.component").then( + (mod) => mod.OrgImportComponent, + ), canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)], data: { titleId: "importData", @@ -61,7 +63,7 @@ const routes: Routes = [ { path: "export", loadComponent: () => - import("../tools/vault-export/org-vault-export.component").then( + import("../../../tools/vault-export/org-vault-export.component").then( (mod) => mod.OrganizationVaultExportComponent, ), canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)], 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/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/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/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,83 @@ 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(["/"]); + + if (this.recoveryCodeLoginFeatureFlagEnabled) { + await this.handleRecoveryLogin(request); + } else { + await this.apiService.postTwoFactorRecover(request); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), + }); + await this.router.navigate(["/"]); + } }; + + /** + * 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"), + }); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), + }); + + 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/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() { 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/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index b565621b776..5597123b24a 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -12,11 +12,6 @@ import { EventsComponent as OrgEventsComponent } from "../admin-console/organiza import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component"; import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component"; -import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../admin-console/organizations/tools/exposed-passwords-report.component"; -import { InactiveTwoFactorReportComponent as OrgInactiveTwoFactorReportComponent } from "../admin-console/organizations/tools/inactive-two-factor-report.component"; -import { ReusedPasswordsReportComponent as OrgReusedPasswordsReportComponent } from "../admin-console/organizations/tools/reused-passwords-report.component"; -import { UnsecuredWebsitesReportComponent as OrgUnsecuredWebsitesReportComponent } from "../admin-console/organizations/tools/unsecured-websites-report.component"; -import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from "../admin-console/organizations/tools/weak-passwords-report.component"; import { HintComponent } from "../auth/hint.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; @@ -65,6 +60,13 @@ import { ProductSwitcherModule } from "../layouts/product-switcher/product-switc import { UserLayoutComponent } from "../layouts/user-layout.component"; import { DomainRulesComponent } from "../settings/domain-rules.component"; import { PreferencesComponent } from "../settings/preferences.component"; +/* eslint no-restricted-imports: "off" -- Temporarily disabled until Tools refactors these out of this module */ +import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../tools/reports/pages/organizations/exposed-passwords-report.component"; +import { InactiveTwoFactorReportComponent as OrgInactiveTwoFactorReportComponent } from "../tools/reports/pages/organizations/inactive-two-factor-report.component"; +import { ReusedPasswordsReportComponent as OrgReusedPasswordsReportComponent } from "../tools/reports/pages/organizations/reused-passwords-report.component"; +import { UnsecuredWebsitesReportComponent as OrgUnsecuredWebsitesReportComponent } from "../tools/reports/pages/organizations/unsecured-websites-report.component"; +import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from "../tools/reports/pages/organizations/weak-passwords-report.component"; +/* eslint no-restricted-imports: "error" */ import { AddEditComponent as SendAddEditComponent } from "../tools/send/add-edit.component"; import { PremiumBadgeComponent } from "../vault/components/premium-badge.component"; import { AddEditCustomFieldsComponent } from "../vault/individual-vault/add-edit-custom-fields.component"; diff --git a/apps/web/src/app/admin-console/organizations/settings/org-import.component.html b/apps/web/src/app/tools/import/org-import.component.html similarity index 100% rename from apps/web/src/app/admin-console/organizations/settings/org-import.component.html rename to apps/web/src/app/tools/import/org-import.component.html diff --git a/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts b/apps/web/src/app/tools/import/org-import.component.ts similarity index 92% rename from apps/web/src/app/admin-console/organizations/settings/org-import.component.ts rename to apps/web/src/app/tools/import/org-import.component.ts index e7a0051253f..90c13833ffc 100644 --- a/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts +++ b/apps/web/src/app/tools/import/org-import.component.ts @@ -14,8 +14,9 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ImportCollectionServiceAbstraction } from "@bitwarden/importer-core"; import { ImportComponent } from "@bitwarden/importer-ui"; -import { LooseComponentsModule, SharedModule } from "../../../shared"; -import { ImportCollectionAdminService } from "../../../tools/import/import-collection-admin.service"; +import { LooseComponentsModule, SharedModule } from "../../shared"; + +import { ImportCollectionAdminService } from "./import-collection-admin.service"; @Component({ templateUrl: "org-import.component.html", diff --git a/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts similarity index 92% rename from apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts rename to apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts index 5601c2f141f..7658da2e793 100644 --- a/apps/web/src/app/admin-console/organizations/tools/exposed-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts @@ -19,12 +19,11 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; -// eslint-disable-next-line no-restricted-imports -import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent } from "../../../tools/reports/pages/exposed-passwords-report.component"; +import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent } from "../exposed-passwords-report.component"; @Component({ selector: "app-org-exposed-passwords-report", - templateUrl: "../../../tools/reports/pages/exposed-passwords-report.component.html", + templateUrl: "../exposed-passwords-report.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class ExposedPasswordsReportComponent diff --git a/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts similarity index 91% rename from apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts rename to apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts index 4ddbd58efc3..8db0389108e 100644 --- a/apps/web/src/app/admin-console/organizations/tools/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -17,12 +17,11 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; -// eslint-disable-next-line no-restricted-imports -import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponent } from "../../../tools/reports/pages/inactive-two-factor-report.component"; +import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponent } from "../inactive-two-factor-report.component"; @Component({ selector: "app-inactive-two-factor-report", - templateUrl: "../../../tools/reports/pages/inactive-two-factor-report.component.html", + templateUrl: "../inactive-two-factor-report.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class InactiveTwoFactorReportComponent diff --git a/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts similarity index 92% rename from apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts rename to apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts index 19f5d3607fa..a15078d48f4 100644 --- a/apps/web/src/app/admin-console/organizations/tools/reused-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts @@ -18,12 +18,11 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; -// eslint-disable-next-line no-restricted-imports -import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } from "../../../tools/reports/pages/reused-passwords-report.component"; +import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } from "../reused-passwords-report.component"; @Component({ selector: "app-reused-passwords-report", - templateUrl: "../../../tools/reports/pages/reused-passwords-report.component.html", + templateUrl: "../reused-passwords-report.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class ReusedPasswordsReportComponent diff --git a/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts similarity index 91% rename from apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts rename to apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts index f6c1f9cff6d..a67116cc44c 100644 --- a/apps/web/src/app/admin-console/organizations/tools/unsecured-websites-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts @@ -17,12 +17,11 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; -// eslint-disable-next-line no-restricted-imports -import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponent } from "../../../tools/reports/pages/unsecured-websites-report.component"; +import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponent } from "../unsecured-websites-report.component"; @Component({ selector: "app-unsecured-websites-report", - templateUrl: "../../../tools/reports/pages/unsecured-websites-report.component.html", + templateUrl: "../unsecured-websites-report.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class UnsecuredWebsitesReportComponent diff --git a/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts similarity index 93% rename from apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts rename to apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts index 7c1c71b934d..a476a50e736 100644 --- a/apps/web/src/app/admin-console/organizations/tools/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts @@ -19,12 +19,11 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "@bitwarden/vault"; -// eslint-disable-next-line no-restricted-imports -import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from "../../../tools/reports/pages/weak-passwords-report.component"; +import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from "../weak-passwords-report.component"; @Component({ selector: "app-weak-passwords-report", - templateUrl: "../../../tools/reports/pages/weak-passwords-report.component.html", + templateUrl: "../weak-passwords-report.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class WeakPasswordsReportComponent diff --git a/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.html b/apps/web/src/app/tools/vault-export/org-vault-export.component.html similarity index 100% rename from apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.html rename to apps/web/src/app/tools/vault-export/org-vault-export.component.html diff --git a/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts b/apps/web/src/app/tools/vault-export/org-vault-export.component.ts similarity index 92% rename from apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts rename to apps/web/src/app/tools/vault-export/org-vault-export.component.ts index 93b8ebd1f23..d84d2b26a90 100644 --- a/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts +++ b/apps/web/src/app/tools/vault-export/org-vault-export.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute } from "@angular/router"; import { ExportComponent } from "@bitwarden/vault-export-ui"; -import { LooseComponentsModule, SharedModule } from "../../../../shared"; +import { LooseComponentsModule, SharedModule } from "../../shared"; @Component({ templateUrl: "org-vault-export.component.html", 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-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index f1241ef3280..9c002a4ed94 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, Subject } from "rxjs"; +import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -109,6 +109,19 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.activeFilter.selectedCipherTypeNode = (await this.getDefaultFilter()) as TreeNode; this.isLoaded = true; + + // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update + merge( + this.policyService.get$(PolicyType.SingleOrg), + this.policyService.get$(PolicyType.PersonalOwnership), + ) + .pipe( + switchMap(() => this.addOrganizationFilter()), + takeUntil(this.destroy$), + ) + .subscribe((orgFilters) => { + this.filters.organizationFilter = orgFilters; + }); } ngOnDestroy() { 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 0f48595f09b..d8b8c5cd38c 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" }, @@ -4484,6 +4490,24 @@ "encryptionKeyUpdateCannotProceed": { "message": "Encryption key update cannot proceed" }, + "editFieldLabel": { + "message": "Edit $LABEL$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, + "reorderToggleButton": { + "message": "Reorder $LABEL$. Use arrow key to move item up or down.", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, "keyUpdateFoldersFailed": { "message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed." }, @@ -9269,7 +9293,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.", @@ -10264,6 +10293,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/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/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); } diff --git a/libs/auth/package.json b/libs/auth/package.json index 3a915d727b1..52c1be63f81 100644 --- a/libs/auth/package.json +++ b/libs/auth/package.json @@ -16,10 +16,5 @@ "clean": "rimraf dist", "build": "npm run clean && tsc", "build:watch": "npm run clean && tsc -watch" - }, - "dependencies": { - "@bitwarden/angular": "file:../angular", - "@bitwarden/common": "file:../common", - "@bitwarden/components": "file:../components" } } 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' }" >
-