diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index faf119cce2..0a343be878 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -29,7 +29,7 @@ on: default: false target_ref: default: "main" - description: "Branch/Tag to target for cut" + description: "Branch/Tag to target for cut (ignored if not cutting rc)" required: true type: string version_number_override: @@ -102,11 +102,12 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for committing and pushing to current branch - name: Check out branch uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - ref: main + ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token }} persist-credentials: true @@ -467,6 +468,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for creating and pushing new branch - name: Check out target ref uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 5a75a21dcd..266cf79d8b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -28,7 +28,7 @@ const preview: Preview = { ], parameters: { a11y: { - element: "#storybook-root", + context: "#storybook-root", }, controls: { matchers: { diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2114949948..6a7df1678b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1475,6 +1475,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 96809fa26b..c8c938a99b 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -69,8 +69,8 @@ export type FieldRect = { }; export type InlineMenuPosition = { - button?: InlineMenuElementPosition; - list?: InlineMenuElementPosition; + button?: InlineMenuElementPosition | null; + list?: InlineMenuElementPosition | null; }; export type NewLoginCipherData = { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index af8141f1ab..04a5339513 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1424,11 +1424,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { } /** - * calculates the postion and width for multi-input totp field inline menu - * @param totpFieldArray - the totp fields used to evaluate the position of the menu + * calculates the position and width for multi-input TOTP field inline menu + * @param totpFieldArray - the TOTP fields used to evaluate the position of the menu */ private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) { - // Filter the fields based on the provided totpfields + // Filter the fields based on the provided TOTP fields const filteredObjects = this.allFieldData.filter((obj) => totpFieldArray.some((o) => o.opid === obj.opid), ); @@ -1451,8 +1451,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { } /** - * calculates the postion for multi-input totp field inline button - * @param totpFieldArray - the totp fields used to evaluate the position of the menu + * calculates the position for multi-input TOTP field inline button + * @param totpFieldArray - the TOTP fields used to evaluate the position of the menu */ private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) { const filteredObjects = this.allFieldData.filter((obj) => diff --git a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts index b341be28eb..6ad069ad56 100644 --- a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts @@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & matches: string[]; excludeMatches: string[]; allFrames: true; - world?: "MAIN" | "ISOLATED"; }; type Fido2ExtensionMessage = { diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 76ad78a6cd..752851b3d3 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -203,7 +203,6 @@ describe("Fido2Background", () => { { file: Fido2ContentScript.PageScriptDelayAppend }, { file: Fido2ContentScript.ContentScript }, ], - world: "ISOLATED", ...sharedRegistrationOptions, }); }); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 0ee7a43767..22ee4a1822 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface { { file: await this.getFido2PageScriptAppendFileName() }, { file: Fido2ContentScript.ContentScript }, ], - world: "ISOLATED", ...this.sharedRegistrationOptions, }); } diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts index 0b10841e39..b444c96708 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts @@ -29,48 +29,38 @@ describe("FIDO2 page-script for manifest v2", () => { expect(window.document.createElement).not.toHaveBeenCalled(); }); - it("appends the `page-script.js` file to the document head when the contentType is `text/html`", async () => { - const scriptContents = "test-script-contents"; + it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => { jest.spyOn(window.document.head, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); - window.fetch = jest.fn().mockResolvedValue({ - text: () => Promise.resolve(scriptContents), - } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); - await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.head.prepend).toHaveBeenCalledWith(expect.any(HTMLScriptElement)); - expect(createdScriptElement.innerHTML).toBe(scriptContents); + expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); }); - it("appends the `page-script.js` file to the document element if the head is not available", async () => { - const scriptContents = "test-script-contents"; + it("appends the `page-script.js` file to the document element if the head is not available", () => { window.document.documentElement.removeChild(window.document.head); jest.spyOn(window.document.documentElement, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); - window.fetch = jest.fn().mockResolvedValue({ - text: () => Promise.resolve(scriptContents), - } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); - await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.documentElement.prepend).toHaveBeenCalledWith( expect.any(HTMLScriptElement), ); - expect(createdScriptElement.innerHTML).toBe(scriptContents); + expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); }); }); diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 8c0d17c7e2..ffa6f7051c 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -2,26 +2,17 @@ * This script handles injection of the FIDO2 override page script into the document. * This is required for manifest v2, but will be removed when we migrate fully to manifest v3. */ -void (async function (globalContext) { +(function (globalContext) { if (globalContext.document.contentType !== "text/html") { return; } const script = globalContext.document.createElement("script"); + // We're removing stack trace information in the page script instead + // eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage + script.src = chrome.runtime.getURL("content/fido2-page-script.js"); script.async = false; - const pageScriptUrl = chrome.runtime.getURL("content/fido2-page-script.js"); - // Inject the script contents directly to avoid leaking the extension URL - try { - const response = await fetch(pageScriptUrl); - const scriptContents = await response.text(); - script.innerHTML = scriptContents; - } catch { - // eslint-disable-next-line no-console - console.error("Failed to load FIDO2 page script contents. Injection failed."); - return; - } - // We are ensuring that the script injection is delayed in the event that we are loading // within an iframe element. This prevents an issue with web mail clients that load content // using ajax within iframes. In particular, Zimbra web mail client was observed to have this issue. diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts index 3bb86ee787..f1ed6875f9 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { EVENTS } from "@bitwarden/common/autofill/constants"; -import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum"; import { createPortSpyMock } from "../../../spec/autofill-mocks"; @@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => { ); }); - // TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked - it.skip("creates an aria alert element if the ariaAlert param is passed", () => { - const ariaAlert = "aria alert"; + it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => { jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement"); autofillInlineMenuIframeService.initMenuIframe(); - expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith( - ariaAlert, + expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe( + "alert", ); - expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe( + "polite", + ); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe( + "true", + ); + }); + + it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => { + const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" }); + const serviceWithoutAlert = new AutofillInlineMenuIframeService( + shadowWithoutAlert, + AutofillOverlayPort.Button, + { height: "0px" }, + "title", + ); + jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement"); + + serviceWithoutAlert.initMenuIframe(); + + expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled(); + expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined(); }); describe("on load of the iframe source", () => { @@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => { sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).not.toHaveBeenCalled(); }); @@ -216,7 +237,7 @@ describe("AutofillInlineMenuIframeService", () => { expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); }); @@ -234,14 +255,14 @@ describe("AutofillInlineMenuIframeService", () => { it("passes the message on to the iframe element", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).not.toHaveBeenCalled(); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); @@ -249,18 +270,18 @@ describe("AutofillInlineMenuIframeService", () => { window.matchMedia = jest.fn(() => mock({ matches: false })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }, autofillInlineMenuIframeService["extensionOrigin"], ); @@ -270,18 +291,18 @@ describe("AutofillInlineMenuIframeService", () => { window.matchMedia = jest.fn(() => mock({ matches: true })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }, autofillInlineMenuIframeService["extensionOrigin"], ); @@ -290,7 +311,7 @@ describe("AutofillInlineMenuIframeService", () => { it("updates the border to match the `dark` theme", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }; sendPortMessage(portSpy, message); @@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => { autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"], ).toHaveBeenCalled(); }); + + it("closes the inline menu when iframe is outside the viewport (bottom)", () => { + const viewportHeight = 800; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: viewportHeight + 1, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: viewportHeight, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (right)", () => { + const viewportWidth = 1200; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: viewportWidth + 1, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: viewportWidth, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (left)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: -1, + right: 0, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (top)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: -1, + left: 0, + right: 100, + bottom: 0, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("allows iframe (do not close) when it has no dimensions", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + height: 0, + width: 0, + } as DOMRect); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("uses visualViewport when available", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: 700, + height: 98, + width: 262, + } as DOMRect); + + Object.defineProperty(globalThis.window, "visualViewport", { + value: { + height: 600, + width: 1200, + } as VisualViewport, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); }); it("updates the visibility of the iframe", () => { @@ -381,7 +615,7 @@ describe("AutofillInlineMenuIframeService", () => { }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "updateAutofillInlineMenuColorScheme", diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index 8b1423b129..64ef7d180e 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -282,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position; this.updateElementStyles(this.iframe, styles); + const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport( + this.iframe.getBoundingClientRect(), + ); + + if (!elementHeightCompletelyInViewport) { + this.forceCloseInlineMenu(); + return; + } + if (this.fadeInTimeout) { this.handleFadeInInlineMenuIframe(); } @@ -289,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.announceAriaAlert(this.ariaAlert, 2000); } + /** + * Check if element is completely within the browser viewport. + */ + private isElementCompletelyWithinViewport(elementPosition: DOMRect) { + // An element that lacks size should be considered within the viewport + if (!elementPosition.height || !elementPosition.width) { + return true; + } + + const [viewportHeight, viewportWidth] = this.getViewportSize(); + + const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth; + const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0; + const topSideIsWithinViewport = (elementPosition.top || 0) >= 0; + const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight; + + return ( + rightSideIsWithinViewport && + leftSideIsWithinViewport && + topSideIsWithinViewport && + bottomSideIsWithinViewport + ); + } + + /** Use Visual Viewport API if available (better for mobile/zoom) */ + private getViewportSize(): [ + VisualViewport["height"] | Window["innerHeight"], + VisualViewport["width"] | Window["innerWidth"], + ] { + if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) { + return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width]; + } + + return [globalThis.window.innerHeight, globalThis.window.innerWidth]; + } + /** * Gets the page color scheme meta tag and sends a message to the iframe * to update its color scheme. Will default to "normal" if the meta tag diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 6f2c00a4dd..367599f7ad 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1400,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, { root: null, rootMargin: "0px", - threshold: 1.0, + threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1, }); } diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.html b/apps/browser/src/billing/popup/settings/premium-v2.component.html index 47d72751af..fea3e55805 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -12,7 +12,7 @@
  • - {{ "ppremiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
  • {{ "premiumSignUpTwoStepOptions" | i18n }} diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.ts b/apps/browser/src/billing/popup/settings/premium-v2.component.ts index b858b74242..0c246d734e 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.ts +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.ts @@ -1,13 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule, CurrencyPipe, Location } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, ], }) -export class PremiumV2Component extends BasePremiumComponent { +export class PremiumV2Component extends BasePremiumComponent implements OnInit { priceString: string; constructor( @@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); - + } + async ngOnInit() { + await super.ngOnInit(); // Support old price string. Can be removed in future once all translations are properly updated. const thePrice = this.currencyPipe.transform(this.price, "$"); // Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix. const formattedPrice = this.platformUtilsService.isSafari() ? thePrice.replace("$", "$$$") : thePrice; - this.priceString = i18nService.t("premiumPriceV2", formattedPrice); + this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice); if (this.priceString.indexOf("%price%") > -1) { this.priceString = this.priceString.replace("%price%", thePrice); } diff --git a/apps/cli/package.json b/apps/cli/package.json index adddc99b4d..d041f818c2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -88,7 +88,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.18", + "tldts": "7.0.19", "zxcvbn": "4.4.2" } } diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 5059a6e4d0..006055f475 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { Component } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; @@ -7,11 +7,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-layout", imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent], templateUrl: "./desktop-layout.component.html", - changeDetection: ChangeDetectionStrategy.OnPush, }) export class DesktopLayoutComponent { protected readonly logo = PasswordManagerLogo; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html new file mode 100644 index 0000000000..20cac15138 --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -0,0 +1,110 @@ +
    +
    +
    +
    + +
    +
    + + + +

    {{ "noItemsInList" | i18n }}

    +
    +
    + +
    +
    + + +
    diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 8055bc0766..5798df0989 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -1,22 +1,364 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import * as utils from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; import { SendV2Component } from "./send-v2.component"; +// Mock the invokeMenu utility function +jest.mock("../../../utils", () => ({ + invokeMenu: jest.fn(), +})); + describe("SendV2Component", () => { let component: SendV2Component; let fixture: ComponentFixture; + let sendService: MockProxy; + let searchBarService: MockProxy; + let broadcasterService: MockProxy; + let accountService: MockProxy; + let policyService: MockProxy; beforeEach(async () => { + sendService = mock(); + searchBarService = mock(); + broadcasterService = mock(); + accountService = mock(); + policyService = mock(); + + // Mock sendViews$ observable + sendService.sendViews$ = of([]); + searchBarService.searchText$ = new BehaviorSubject(""); + + // Mock activeAccount$ observable for parent class ngOnInit + accountService.activeAccount$ = of({ id: "test-user-id" } as any); + policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false)); + await TestBed.configureTestingModule({ imports: [SendV2Component], + providers: [ + { provide: SendService, useValue: sendService }, + { provide: I18nService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: EnvironmentService, useValue: mock() }, + { provide: BroadcasterService, useValue: broadcasterService }, + { provide: SearchService, useValue: mock() }, + { provide: PolicyService, useValue: policyService }, + { provide: SearchBarService, useValue: searchBarService }, + { provide: LogService, useValue: mock() }, + { provide: SendApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: AccountService, useValue: accountService }, + ], }).compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; - fixture.detectChanges(); }); it("creates component", () => { expect(component).toBeTruthy(); }); + + it("initializes with correct default action", () => { + expect(component.action).toBe(""); + }); + + it("subscribes to broadcaster service on init", async () => { + await component.ngOnInit(); + expect(broadcasterService.subscribe).toHaveBeenCalledWith( + "SendV2Component", + expect.any(Function), + ); + }); + + it("unsubscribes from broadcaster service on destroy", () => { + component.ngOnDestroy(); + expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component"); + }); + + it("enables search bar on init", async () => { + await component.ngOnInit(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(true); + }); + + it("disables search bar on destroy", () => { + component.ngOnDestroy(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(false); + }); + + describe("addSend", () => { + it("sets action to Add", async () => { + await component.addSend(); + expect(component.action).toBe("add"); + }); + + it("calls resetAndLoad on addEditComponent when component exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.addSend(); + + expect(mockAddEdit.resetAndLoad).toHaveBeenCalled(); + }); + + it("does not throw when addEditComponent is null", async () => { + component.addEditComponent = null; + await expect(component.addSend()).resolves.not.toThrow(); + }); + }); + + describe("cancel", () => { + it("resets action to None", () => { + component.action = "edit"; + component.sendId = "test-id"; + + component.cancel(new SendView()); + + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("deletedSend", () => { + it("refreshes the list and resets action and sendId", async () => { + component.action = "edit"; + component.sendId = "test-id"; + jest.spyOn(component, "refresh").mockResolvedValue(); + + const mockSend = new SendView(); + await component.deletedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("savedSend", () => { + it("refreshes the list and selects the saved send", async () => { + jest.spyOn(component, "refresh").mockResolvedValue(); + jest.spyOn(component, "selectSend").mockResolvedValue(); + + const mockSend = new SendView(); + mockSend.id = "saved-send-id"; + + await component.savedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.selectSend).toHaveBeenCalledWith("saved-send-id"); + }); + }); + + describe("selectSend", () => { + it("sets action to Edit and updates sendId", async () => { + await component.selectSend("new-send-id"); + + expect(component.action).toBe("edit"); + expect(component.sendId).toBe("new-send-id"); + }); + + it("updates addEditComponent when it exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.selectSend("test-send-id"); + + expect(mockAddEdit.sendId).toBe("test-send-id"); + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + + it("does not reload if same send is already selected in edit mode", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "same-id"; + component.action = "edit"; + + await component.selectSend("same-id"); + + expect(mockAddEdit.refresh).not.toHaveBeenCalled(); + }); + + it("reloads if selecting different send", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "old-id"; + component.action = "edit"; + + await component.selectSend("new-id"); + + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + }); + + describe("selectedSendType", () => { + it("returns the type of the currently selected send", () => { + const mockSend1 = new SendView(); + mockSend1.id = "send-1"; + mockSend1.type = SendType.Text; + + const mockSend2 = new SendView(); + mockSend2.id = "send-2"; + mockSend2.type = SendType.File; + + component.sends = [mockSend1, mockSend2]; + component.sendId = "send-2"; + + expect(component.selectedSendType).toBe(SendType.File); + }); + + it("returns undefined when no send is selected", () => { + component.sends = []; + component.sendId = "non-existent"; + + expect(component.selectedSendType).toBeUndefined(); + }); + + it("returns undefined when sendId is null", () => { + const mockSend = new SendView(); + mockSend.id = "send-1"; + mockSend.type = SendType.Text; + + component.sends = [mockSend]; + component.sendId = null; + + expect(component.selectedSendType).toBeUndefined(); + }); + }); + + describe("viewSendMenu", () => { + let mockSend: SendView; + + beforeEach(() => { + mockSend = new SendView(); + mockSend.id = "test-send"; + mockSend.name = "Test Send"; + jest.clearAllMocks(); + }); + + it("creates menu with copy link option", () => { + jest.spyOn(component, "copy").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete + }); + + it("includes remove password option when send has password and is not disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = false; + jest.spyOn(component, "removePassword").mockResolvedValue(true); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(3); // copy link + remove password + delete + }); + + it("excludes remove password option when send has no password", () => { + mockSend.password = null; + mockSend.disabled = false; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("excludes remove password option when send is disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = true; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("always includes delete option", () => { + jest.spyOn(component, "delete").mockResolvedValue(true); + jest.spyOn(component, "deletedSend").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + // Delete is always the last item in the menu + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItems[menuItems.length - 1]).toHaveProperty("label"); + expect(menuItems[menuItems.length - 1]).toHaveProperty("click"); + }); + }); + + describe("search bar subscription", () => { + it("updates searchText when search bar text changes", () => { + const searchSubject = new BehaviorSubject("initial"); + searchBarService.searchText$ = searchSubject; + + // Create new component to trigger constructor subscription + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + + searchSubject.next("new search text"); + + expect(component.searchText).toBe("new search text"); + }); + }); + + describe("load", () => { + it("sets loading states correctly", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + + expect(component.loaded).toBeFalsy(); + + await component.load(); + + expect(component.loading).toBe(false); + expect(component.loaded).toBe(true); + }); + + it("calls selectAll when onSuccessfulLoad is not set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + component.onSuccessfulLoad = null; + + await component.load(); + + expect(component.selectAll).toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoad when it is set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + const mockCallback = jest.fn().mockResolvedValue(undefined); + component.onSuccessfulLoad = mockCallback; + + await component.load(); + + expect(mockCallback).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 4840cd4cce..4afe02d9f9 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -1,9 +1,233 @@ -import { Component, ChangeDetectionStrategy } from "@angular/core"; +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { mergeMap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; + +const Action = Object.freeze({ + /** No action is currently active. */ + None: "", + /** The user is adding a new Send. */ + Add: "add", + /** The user is editing an existing Send. */ + Edit: "edit", +} as const); + +type Action = (typeof Action)[keyof typeof Action]; + +const BroadcasterSubscriptionId = "SendV2Component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-v2", - imports: [], - template: "

    Sends V2 Component

    ", - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, JslibModule, FormsModule, AddEditComponent], + templateUrl: "./send-v2.component.html", }) -export class SendV2Component {} +export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; + + // The ID of the currently selected Send item being viewed or edited + sendId: string; + + // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit) + action: Action = Action.None; + + constructor( + sendService: SendService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + private broadcasterService: BroadcasterService, + ngZone: NgZone, + searchService: SearchService, + policyService: PolicyService, + private searchBarService: SearchBarService, + logService: LogService, + sendApiService: SendApiService, + dialogService: DialogService, + toastService: ToastService, + accountService: AccountService, + private cdr: ChangeDetectorRef, + ) { + super( + sendService, + i18nService, + platformUtilsService, + environmentService, + ngZone, + searchService, + policyService, + logService, + sendApiService, + dialogService, + toastService, + accountService, + ); + + // Listen to search bar changes and update the Send list filter + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.searchBarService.searchText$.subscribe((searchText) => { + this.searchText = searchText; + this.searchTextChanged(); + setTimeout(() => this.cdr.detectChanges(), 250); + }); + } + + // Initialize the component: enable search bar, subscribe to sync events, and load Send items + async ngOnInit() { + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends")); + + await super.ngOnInit(); + + // Listen for sync completion events to refresh the Send list + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + await this.load(); + break; + } + }); + }); + await this.load(); + } + + // Clean up subscriptions and disable search bar when component is destroyed + ngOnDestroy() { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.searchBarService.setEnabled(false); + } + + // Load Send items from the service and display them in the list. + // Subscribes to sendViews$ observable to get updates when Sends change. + // Manually triggers change detection to ensure UI updates immediately. + // Note: The filter parameter is ignored in this implementation for desktop-specific behavior. + async load(filter: (send: SendView) => boolean = null) { + this.loading = true; + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + // Trigger change detection after data updates + this.cdr.detectChanges(); + }), + ) + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(); + if (this.onSuccessfulLoad != null) { + await this.onSuccessfulLoad(); + } else { + // Default action + this.selectAll(); + } + this.loading = false; + this.loaded = true; + } + + // Open the add Send form to create a new Send item + async addSend() { + this.action = Action.Add; + if (this.addEditComponent != null) { + await this.addEditComponent.resetAndLoad(); + } + } + + // Close the add/edit form and return to the list view + cancel(s: SendView) { + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is deleted: refresh the list and close the edit form + async deletedSend(s: SendView) { + await this.refresh(); + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is saved: refresh the list and re-select the saved Send + async savedSend(s: SendView) { + await this.refresh(); + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(s.id); + } + + // Select a Send from the list and open it in the edit form. + // If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads. + async selectSend(sendId: string) { + if (sendId === this.sendId && this.action === Action.Edit) { + return; + } + this.action = Action.Edit; + this.sendId = sendId; + if (this.addEditComponent != null) { + this.addEditComponent.sendId = sendId; + await this.addEditComponent.refresh(); + } + } + + // Get the type (text or file) of the currently selected Send for the edit form + get selectedSendType() { + return this.sends.find((s) => s.id === this.sendId)?.type; + } + + // Show the right-click context menu for a Send with options to copy link, remove password, or delete + viewSendMenu(send: SendView) { + const menu: RendererMenuItem[] = []; + menu.push({ + label: this.i18nService.t("copyLink"), + click: () => this.copy(send), + }); + if (send.password && !send.disabled) { + menu.push({ + label: this.i18nService.t("removePassword"), + click: async () => { + await this.removePassword(send); + if (this.sendId === send.id) { + this.sendId = null; + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(send.id); + } + }, + }); + } + menu.push({ + label: this.i18nService.t("delete"), + click: async () => { + await this.delete(send); + await this.deletedSend(send); + }, + }); + + invokeMenu(menu); + } +} diff --git a/apps/desktop/src/billing/app/accounts/premium.component.html b/apps/desktop/src/billing/app/accounts/premium.component.html index d88602bed1..c5f9722f13 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.html +++ b/apps/desktop/src/billing/app/accounts/premium.component.html @@ -13,7 +13,7 @@
    • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
    • diff --git a/apps/desktop/src/billing/app/accounts/premium.component.ts b/apps/desktop/src/billing/app/accounts/premium.component.ts index 637969c1a2..4aff0cc03e 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.ts +++ b/apps/desktop/src/billing/app/accounts/premium.component.ts @@ -3,6 +3,7 @@ import { Component } from "@angular/core"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -28,6 +29,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -39,6 +41,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f6f078611c..757059c4e4 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1490,6 +1490,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.html b/apps/desktop/src/vault/app/vault-v3/vault.component.html new file mode 100644 index 0000000000..a9a25f5799 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -0,0 +1,70 @@ +
      + + +
      + +
      +
      +
      + + + + + + + +
      +
      +
      +
      + +
      + diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts deleted file mode 100644 index 89ba05055f..0000000000 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VaultComponent } from "./vault.component"; - -describe("VaultComponent", () => { - let component: VaultComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VaultComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(VaultComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("creates component", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index b29b66225c..21ba7547f8 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -1,9 +1,1020 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef, +} from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs"; +import { filter, map, take } from "rxjs/operators"; +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; +import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getByIds } from "@bitwarden/common/platform/misc"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { + BadgeModule, + ButtonModule, + DialogService, + ItemModule, + ToastService, + CopyClickListener, + COPY_CLICK_LISTENER, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { + AddEditFolderDialogComponent, + AddEditFolderDialogResult, + AttachmentDialogResult, + AttachmentsV2Component, + ChangeLoginPasswordService, + CipherFormConfig, + CipherFormConfigService, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, + CipherViewComponent, + CollectionAssignmentResult, + DecryptionFailureDialogComponent, + DefaultChangeLoginPasswordService, + DefaultCipherFormConfigService, + PasswordRepromptService, + CipherFormComponent, + ArchiveCipherUtilitiesService, +} from "@bitwarden/vault"; + +import { SearchBarService } from "../../../app/layout/search/search-bar.service"; +import { DesktopCredentialGenerationService } from "../../../services/desktop-cipher-form-generator.service"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; +import { ItemFooterComponent } from "../vault/item-footer.component"; +import { VaultFilterComponent } from "../vault/vault-filter/vault-filter.component"; +import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module"; +import { VaultItemsV2Component } from "../vault/vault-items-v2.component"; + +const BroadcasterSubscriptionId = "VaultComponent"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault-v3", - imports: [], - template: "

      Vault V3 Component

      ", - changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "vault.component.html", + imports: [ + BadgeModule, + CommonModule, + CipherFormModule, + CipherViewComponent, + ItemFooterComponent, + I18nPipe, + ItemModule, + ButtonModule, + PremiumBadgeComponent, + VaultFilterModule, + VaultItemsV2Component, + ], + providers: [ + { + provide: CipherFormConfigService, + useClass: DefaultCipherFormConfigService, + }, + { + provide: ChangeLoginPasswordService, + useClass: DefaultChangeLoginPasswordService, + }, + { + provide: ViewPasswordHistoryService, + useClass: VaultViewPasswordHistoryService, + }, + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + { provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService }, + { + provide: COPY_CLICK_LISTENER, + useExisting: VaultComponent, + }, + ], }) -export class VaultComponent {} +export class VaultComponent + implements OnInit, OnDestroy, CopyClickListener +{ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(VaultItemsV2Component, { static: true }) + vaultItemsComponent: VaultItemsV2Component | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(VaultFilterComponent, { static: true }) + vaultFilterComponent: VaultFilterComponent | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) + folderAddEditModalRef: ViewContainerRef | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(CipherFormComponent) + cipherFormComponent: CipherFormComponent | null = null; + + action: CipherFormMode | "view" | null = null; + cipherId: string | null = null; + favorites = false; + type: CipherType | null = null; + folderId: string | null = null; + collectionId: string | null = null; + organizationId: string | null = null; + myVaultOnly = false; + addType: CipherType | undefined = undefined; + addOrganizationId: string | null = null; + addCollectionIds: string[] | null = null; + showingModal = false; + deleted = false; + userHasPremiumAccess = false; + activeFilter: VaultFilter = new VaultFilter(); + activeUserId: UserId | null = null; + cipherRepromptId: string | null = null; + cipher: CipherView | null = new CipherView(); + collections: CollectionView[] | null = null; + config: CipherFormConfig | null = null; + + /** Tracks the disabled status of the edit cipher form */ + protected formDisabled: boolean = false; + + private organizations$: Observable = this.accountService.activeAccount$.pipe( + map((a) => a?.id), + filterOutNullish(), + switchMap((id) => this.organizationService.organizations$(id)), + ); + + protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + private componentIsDestroyed$ = new Subject(); + private allOrganizations: Organization[] = []; + private allCollections: CollectionView[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private i18nService: I18nService, + private broadcasterService: BroadcasterService, + private changeDetectorRef: ChangeDetectorRef, + private ngZone: NgZone, + private syncService: SyncService, + private messagingService: MessagingService, + private platformUtilsService: PlatformUtilsService, + private eventCollectionService: EventCollectionService, + private totpService: TotpService, + private passwordRepromptService: PasswordRepromptService, + private searchBarService: SearchBarService, + private apiService: ApiService, + private dialogService: DialogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private toastService: ToastService, + private accountService: AccountService, + private cipherService: CipherService, + private formConfigService: CipherFormConfigService, + private premiumUpgradePromptService: PremiumUpgradePromptService, + private collectionService: CollectionService, + private organizationService: OrganizationService, + private folderService: FolderService, + private configService: ConfigService, + private authRequestService: AuthRequestServiceAbstraction, + private cipherArchiveService: CipherArchiveService, + private policyService: PolicyService, + private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, + ) {} + + async ngOnInit() { + this.accountService.activeAccount$ + .pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((canAccessPremium: boolean) => { + this.userHasPremiumAccess = canAccessPremium; + }); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone + .run(async () => { + let detectChanges = true; + try { + switch (message.command) { + case "newLogin": + await this.addCipher(CipherType.Login).catch(() => {}); + break; + case "newCard": + await this.addCipher(CipherType.Card).catch(() => {}); + break; + case "newIdentity": + await this.addCipher(CipherType.Identity).catch(() => {}); + break; + case "newSecureNote": + await this.addCipher(CipherType.SecureNote).catch(() => {}); + break; + case "newSshKey": + await this.addCipher(CipherType.SshKey).catch(() => {}); + break; + case "focusSearch": + (document.querySelector("#search") as HTMLInputElement)?.select(); + detectChanges = false; + break; + case "syncCompleted": + if (this.vaultItemsComponent) { + await this.vaultItemsComponent + .reload(this.activeFilter.buildFilter()) + .catch(() => {}); + } + if (this.vaultFilterComponent) { + await this.vaultFilterComponent + .reloadCollectionsAndFolders(this.activeFilter) + .catch(() => {}); + await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); + } + break; + case "modalShown": + this.showingModal = true; + break; + case "modalClosed": + this.showingModal = false; + break; + case "copyUsername": { + if (this.cipher?.login?.username) { + this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username"); + } + break; + } + case "copyPassword": { + if (this.cipher?.login?.password && this.cipher.viewPassword) { + this.copyValue(this.cipher, this.cipher.login.password, "password", "Password"); + await this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id) + .catch(() => {}); + } + break; + } + case "copyTotp": { + if ( + this.cipher?.login?.hasTotp && + (this.cipher.organizationUseTotp || this.userHasPremiumAccess) + ) { + const value = await firstValueFrom( + this.totpService.getCode$(this.cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); + } + } + break; + } + default: + detectChanges = false; + break; + } + } catch { + // Ignore errors + } + if (detectChanges) { + this.changeDetectorRef.detectChanges(); + } + }) + .catch(() => {}); + }); + + if (!this.syncService.syncInProgress) { + await this.load().catch(() => {}); + } + + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); + + const authRequests = await firstValueFrom( + this.authRequestService.getLatestPendingAuthRequest$()!, + ); + if (authRequests != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequests.id, + }); + } + + this.activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ).catch((): any => null); + + if (this.activeUserId) { + this.cipherService + .failedToDecryptCiphers$(this.activeUserId) + .pipe( + map((ciphers) => ciphers?.filter((c) => !c.isDeleted) ?? []), + filter((ciphers) => ciphers.length > 0), + take(1), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((ciphers) => { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: ciphers.map((c) => c.id as CipherId), + }); + }); + } + + this.organizations$.pipe(takeUntil(this.componentIsDestroyed$)).subscribe((orgs) => { + this.allOrganizations = orgs; + }); + + if (!this.activeUserId) { + throw new Error("No user found."); + } + + this.collectionService + .decryptedCollections$(this.activeUserId) + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((collections) => { + this.allCollections = collections; + }); + } + + ngOnDestroy() { + this.searchBarService.setEnabled(false); + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); + } + + async load() { + const params = await firstValueFrom(this.route.queryParams).catch(); + const paramCipherAddType = toCipherType(params.addType); + if (params.cipherId) { + const cipherView = new CipherView(); + cipherView.id = params.cipherId; + if (params.action === "clone") { + await this.cloneCipher(cipherView).catch(() => {}); + } else if (params.action === "edit") { + await this.editCipher(cipherView).catch(() => {}); + } else { + await this.viewCipher(cipherView).catch(() => {}); + } + } else if (params.action === "add" && paramCipherAddType) { + this.addType = paramCipherAddType; + await this.addCipher(this.addType).catch(() => {}); + } + + const paramCipherType = toCipherType(params.type); + this.activeFilter = new VaultFilter({ + status: params.deleted ? "trash" : params.favorites ? "favorites" : "all", + cipherType: params.action === "add" || paramCipherType == null ? undefined : paramCipherType, + selectedFolderId: params.folderId, + selectedCollectionId: params.selectedCollectionId, + selectedOrganizationId: params.selectedOrganizationId, + myVaultOnly: params.myVaultOnly ?? false, + }); + if (this.vaultItemsComponent) { + await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {}); + } + } + + /** + * Handler for Vault level CopyClickDirectives to send the minimizeOnCopy message + */ + onCopy() { + this.messagingService.send("minimizeOnCopy"); + } + + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [c.id as CipherId], + }); + return; + } + const cipher = await this.cipherService.getFullCipherView(c); + if (await this.shouldReprompt(cipher, "view")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + this.collections = + this.vaultFilterComponent?.collections?.fullList.filter((c) => + cipher.collectionIds.includes(c.id), + ) ?? null; + this.action = "view"; + + await this.go().catch(() => {}); + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + cipher.id, + false, + cipher.organizationId, + ); + } + + formStatusChanged(status: "disabled" | "enabled") { + this.formDisabled = status === "disabled"; + } + + async openAttachmentsDialog() { + if (!this.userHasPremiumAccess) { + return; + } + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: this.cipherId as CipherId, + }); + const result = await firstValueFrom(dialogRef.closed).catch((): any => null); + if ( + result?.action === AttachmentDialogResult.Removed || + result?.action === AttachmentDialogResult.Uploaded + ) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (this.cipherFormComponent == null) { + return; + } + + // The encrypted state of ciphers is updated when an attachment is added, + // but the cache is also cleared. Depending on timing, `cipherService.get` can return the + // old cipher. Retrieve the updated cipher from `cipherViews$`, + // which refreshes after the cached is cleared. + const updatedCipherView = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId)), + ), + ); + + // `find` can return undefined but that shouldn't happen as + // this would mean that the cipher was deleted. + // To make TypeScript happy, exit early if it isn't found. + if (!updatedCipherView) { + return; + } + + this.cipherFormComponent.patchCipher((currentCipher) => { + currentCipher.attachments = updatedCipherView.attachments; + currentCipher.revisionDate = updatedCipherView.revisionDate; + + return currentCipher; + }); + } + } + + async viewCipherMenu(c: CipherViewLike) { + const cipher = await this.cipherService.getFullCipherView(c); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId)); + const orgOwnershipPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), + ); + + const menu: RendererMenuItem[] = [ + { + label: this.i18nService.t("view"), + click: () => { + this.functionWithChangeDetection(() => { + this.viewCipher(cipher).catch(() => {}); + }); + }, + }, + ]; + + if (cipher.decryptionFailure) { + invokeMenu(menu); + } + + if (!cipher.isDeleted) { + menu.push({ + label: this.i18nService.t("edit"), + click: () => { + this.functionWithChangeDetection(() => { + this.editCipher(cipher).catch(() => {}); + }); + }, + }); + + const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy; + const canCloneArchived = !cipher.isArchived || userCanArchive; + + if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) { + menu.push({ + label: this.i18nService.t("clone"), + click: () => { + this.functionWithChangeDetection(() => { + this.cloneCipher(cipher).catch(() => {}); + }); + }, + }); + } + + const hasEditableCollections = this.allCollections.some((collection) => !collection.readOnly); + + if (cipher.canAssignToCollections && hasEditableCollections) { + menu.push({ + label: this.i18nService.t("assignToCollections"), + click: () => + this.functionWithChangeDetection(async () => { + await this.shareCipher(cipher); + }), + }); + } + } + + if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + menu.push({ + label: this.i18nService.t("archiveVerb"), + click: async () => { + if (!userCanArchive) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + + await this.archiveCipherUtilitiesService.archiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + if (cipher.isArchived) { + menu.push({ + label: this.i18nService.t("unArchive"), + click: async () => { + await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } + invokeMenu(menu); + } + + async shouldReprompt(cipher: CipherView, action: "edit" | "clone" | "view"): Promise { + return !(await this.canNavigateAway(action, cipher)) || !(await this.passwordReprompt(cipher)); + } + + async buildFormConfig(action: CipherFormMode) { + this.config = await this.formConfigService + .buildConfig(action, this.cipherId as CipherId, this.addType) + .catch((): any => null); + } + + async editCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "edit")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("edit"); + if (!cipher.edit && this.config) { + this.config.mode = "partial-edit"; + } + this.action = "edit"; + await this.go().catch(() => {}); + } + + async cloneCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "clone")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("clone"); + this.action = "clone"; + await this.go().catch(() => {}); + } + + async shareCipher(cipher: CipherView) { + if (!cipher) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (!(await this.passwordReprompt(cipher))) { + return; + } + + const availableCollections = this.getAvailableCollections(cipher); + + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: { + ciphers: [cipher], + organizationId: cipher.organizationId as OrganizationId, + availableCollections, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + const updatedCipher = await firstValueFrom( + // Fetch the updated cipher from the service + this.cipherService.cipherViews$(this.activeUserId as UserId).pipe( + filter((ciphers) => ciphers != null), + map((ciphers) => ciphers!.find((c) => c.id === cipher.id)), + filter((foundCipher) => foundCipher != null), + ), + ); + await this.savedCipher(updatedCipher); + } + } + + async addCipher(type: CipherType) { + if (this.action === "add") { + return; + } + this.addType = type || this.activeFilter.cipherType; + this.cipher = new CipherView(); + this.cipherId = null; + await this.buildFormConfig("add"); + this.action = "add"; + this.prefillCipherFromFilter(); + await this.go().catch(() => {}); + + if (type === CipherType.SshKey) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } + } + + async savedCipher(cipher: CipherView) { + this.cipherId = null; + this.action = "view"; + await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (!this.activeUserId) { + throw new Error("No userId provided."); + } + + this.collections = await firstValueFrom( + this.collectionService + .decryptedCollections$(this.activeUserId) + .pipe(getByIds(cipher.collectionIds)), + ); + + this.cipherId = cipher.id; + this.cipher = cipher; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async deleteCipher() { + this.cipherId = null; + this.cipher = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async restoreCipher() { + this.cipherId = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async cancelCipher(cipher: CipherView) { + this.cipherId = cipher.id; + this.cipher = cipher; + this.action = this.cipherId ? "view" : null; + await this.go().catch(() => {}); + } + + async applyVaultFilter(vaultFilter: VaultFilter) { + this.searchBarService.setPlaceholderText( + this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), + ); + this.activeFilter = vaultFilter; + await this.vaultItemsComponent + ?.reload( + this.activeFilter.buildFilter(), + vaultFilter.status === "trash", + vaultFilter.status === "archive", + ) + .catch(() => {}); + await this.go().catch(() => {}); + } + + private getAvailableCollections(cipher: CipherView): CollectionView[] { + const orgId = cipher.organizationId; + if (!orgId || orgId === "MyVault") { + return []; + } + + const organization = this.allOrganizations.find((o) => o.id === orgId); + return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); + } + + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { + if (vaultFilter.status === "favorites") { + return "searchFavorites"; + } + if (vaultFilter.status === "trash") { + return "searchTrash"; + } + if (vaultFilter.cipherType != null) { + return "searchType"; + } + if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") { + return "searchFolder"; + } + if (vaultFilter.selectedCollectionId != null) { + return "searchCollection"; + } + if (vaultFilter.selectedOrganizationId != null) { + return "searchOrganization"; + } + if (vaultFilter.myVaultOnly) { + return "searchMyVault"; + } + return "searchVault"; + } + + async addFolder() { + this.messagingService.send("newFolder"); + } + + async editFolder(folderId: string) { + if (!this.activeUserId) { + return; + } + const folderView = await firstValueFrom( + this.folderService.getDecrypted$(folderId, this.activeUserId), + ); + + if (!folderView) { + return; + } + + const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, { + editFolderConfig: { + folder: { + ...folderView, + }, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if ( + result === AddEditFolderDialogResult.Deleted || + result === AddEditFolderDialogResult.Created + ) { + await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter); + } + } + + /** Refresh the current cipher object */ + protected async refreshCurrentCipher() { + if (!this.cipher) { + return; + } + + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), + ); + } + + private dirtyInput(): boolean { + return ( + (this.action === "add" || this.action === "edit" || this.action === "clone") && + document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0 + ); + } + + private async wantsToSaveChanges(): Promise { + const confirmed = await this.dialogService + .openSimpleDialog({ + title: { key: "unsavedChangesTitle" }, + content: { key: "unsavedChangesConfirmation" }, + type: "warning", + }) + .catch(() => false); + return !confirmed; + } + + private async go(queryParams: any = null) { + if (queryParams == null) { + queryParams = { + action: this.action, + cipherId: this.cipherId, + favorites: this.favorites ? true : null, + type: this.type, + folderId: this.folderId, + collectionId: this.collectionId, + deleted: this.deleted ? true : null, + organizationId: this.organizationId, + myVaultOnly: this.myVaultOnly, + }; + } + this.router + .navigate([], { + relativeTo: this.route, + queryParams: queryParams, + replaceUrl: true, + }) + .catch(() => {}); + } + + private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) { + this.functionWithChangeDetection(() => { + (async () => { + if ( + cipher.reprompt !== CipherRepromptType.None && + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.passwordReprompt(cipher)) + ) { + return; + } + this.platformUtilsService.copyToClipboard(value); + this.toastService.showToast({ + variant: "info", + title: undefined, + message: this.i18nService.t("valueCopied", this.i18nService.t(labelI18nKey)), + }); + this.messagingService.send("minimizeOnCopy"); + })().catch(() => {}); + }); + } + + private functionWithChangeDetection(func: () => void) { + this.ngZone.run(() => { + func(); + this.changeDetectorRef.detectChanges(); + }); + } + + private prefillCipherFromFilter() { + if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) { + const collections = this.vaultFilterComponent.collections?.fullList.filter( + (c) => c.id === this.activeFilter.selectedCollectionId, + ); + if (collections.length > 0) { + this.addOrganizationId = collections[0].organizationId; + this.addCollectionIds = [this.activeFilter.selectedCollectionId]; + } + } else if (this.activeFilter.selectedOrganizationId) { + this.addOrganizationId = this.activeFilter.selectedOrganizationId; + } else { + // clear out organizationId when the user switches to a personal vault filter + this.addOrganizationId = null; + } + if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { + this.folderId = this.activeFilter.selectedFolderId; + } + + if (this.config == null) { + return; + } + + this.config.initialValues = { + ...this.config.initialValues, + organizationId: this.addOrganizationId as OrganizationId, + }; + } + + private async canNavigateAway(action: string, cipher?: CipherView) { + if (this.action === action && (!cipher || this.cipherId === cipher.id)) { + return false; + } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { + return false; + } + return true; + } + + private async passwordReprompt(cipher: CipherView) { + if (cipher.reprompt === CipherRepromptType.None) { + this.cipherRepromptId = null; + return true; + } + if (this.cipherRepromptId === cipher.id) { + return true; + } + const repromptResult = await this.passwordRepromptService.showPasswordPrompt(); + if (repromptResult) { + this.cipherRepromptId = cipher.id; + } + return repromptResult; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index a5399de920..344a78f2a2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.12.1", + "version": "2025.12.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html index 63c26bd61f..33e89f21fc 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -24,7 +24,7 @@
      • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
      • @@ -82,7 +82,10 @@ /> {{ "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + | i18n + : `${(providedStorageGb$ | async)} GB` + : (storagePrice$ | async | currency: "$") + : ("year" | i18n) }}
diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts index fceeeedf17..86a508d270 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -22,8 +22,8 @@ import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent { return { seat: premiumPlan.passwordManager.annualPrice, storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + providedStorageGb: premiumPlan.passwordManager.providedStorageGB, }; }), shareReplay({ bufferSize: 1, refCount: true }), @@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent { storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb)); + protected isLoadingPrices$ = this.premiumPrices$.pipe( map(() => false), startWith(true), @@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, - private subscriptionPricingService: DefaultSubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, ) { this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 0fd7746fc9..978bb35c5c 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -620,7 +620,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get storageGb() { - return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0; + return Math.max( + 0, + (this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb, + ); } passwordManagerSeatTotal(plan: PlanResponse): number { @@ -644,12 +647,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - return ( - plan.PasswordManager.additionalStoragePricePerGb * - // TODO: Eslint upgrade. Please resolve this since the null check does nothing - // eslint-disable-next-line no-constant-binary-expression - Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0) - ); + return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb; } additionalStoragePriceMonthly(selectedPlan: PlanResponse) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 6234fc6e6e..d06604ba29 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -104,7 +104,7 @@
  • {{ "gbEncryptedFileStorage" - | i18n: selectableProduct.PasswordManager.baseStorageGb + "GB" + | i18n: selectableProduct.PasswordManager.baseStorageGb + " GB" }}
  • @@ -239,7 +239,7 @@ {{ "additionalStorageIntervalDesc" | i18n - : "1 GB" + : `${selectedPlan.PasswordManager.baseStorageGb} GB` : (additionalStoragePriceMonthly(selectedPlan) | currency: "$") : ("month" | i18n) }} 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 561a3e03de..67f6f9b0a6 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -654,6 +654,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.singleOrgPolicyBlock) { return; } + + // Validate billing form for paid plans during creation + if (this.createOrganization && this.selectedPlan.type !== PlanType.Free) { + this.billingFormGroup.markAllAsTouched(); + if (this.billingFormGroup.invalid) { + return; + } + } const doSubmit = async (): Promise => { let orgId: string; if (this.createOrganization) { @@ -703,11 +711,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return orgId; }; - this.formPromise = doSubmit(); - const organizationId = await this.formPromise; - this.onSuccess.emit({ organizationId: organizationId }); - // TODO: No one actually listening to this message? - this.messagingService.send("organizationCreated", { organizationId }); + try { + this.formPromise = doSubmit(); + const organizationId = await this.formPromise; + this.onSuccess.emit({ organizationId: organizationId }); + // TODO: No one actually listening to this message? + this.messagingService.send("organizationCreated", { organizationId }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Payment method validation failed") { + return; + } + throw error; + } }; protected get showTaxIdField(): boolean { @@ -826,6 +841,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return; } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } await this.subscriberBillingClient.updatePaymentMethod( { type: "organization", data: this.organization }, paymentMethod, @@ -877,6 +895,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } const billingAddress = getBillingAddressFromForm( this.billingFormGroup.controls.billingAddress, diff --git a/apps/web/src/app/billing/services/pricing-summary.service.spec.ts b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts new file mode 100644 index 0000000000..4e15d318a0 --- /dev/null +++ b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts @@ -0,0 +1,232 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BillingCustomerDiscount, + OrganizationSubscriptionResponse, +} from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { + PasswordManagerPlanFeaturesResponse, + PlanResponse, + SecretsManagerPlanFeaturesResponse, +} from "@bitwarden/common/billing/models/response/plan.response"; + +import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component"; + +import { PricingSummaryService } from "./pricing-summary.service"; + +describe("PricingSummaryService", () => { + let service: PricingSummaryService; + + beforeEach(() => { + service = new PricingSummaryService(); + }); + + describe("getPricingSummaryData", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + let mockOrganization: Organization; + + beforeEach(() => { + // Create mock plan with password manager features + mockPlan = { + productTier: ProductTierType.Teams, + PasswordManager: { + basePrice: 0, + seatPrice: 48, + baseSeats: 0, + hasAdditionalSeatsOption: true, + hasPremiumAccessOption: false, + premiumAccessOptionPrice: 0, + hasAdditionalStorageOption: true, + additionalStoragePricePerGb: 6, + baseStorageGb: 1, + } as PasswordManagerPlanFeaturesResponse, + SecretsManager: { + basePrice: 0, + seatPrice: 72, + baseSeats: 3, + hasAdditionalSeatsOption: true, + hasAdditionalServiceAccountOption: true, + additionalPricePerServiceAccount: 6, + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + // Create mock subscription + mockSub = { + seats: 5, + smSeats: 5, + smServiceAccounts: 5, + maxStorageGb: 2, + customerDiscount: null, + } as OrganizationSubscriptionResponse; + + // Create mock organization + mockOrganization = { + useSecretsManager: false, + } as Organization; + }); + + it("should calculate pricing data correctly for password manager only", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, // estimatedTax + ); + + expect(result).toEqual({ + selectedPlanInterval: "month", + passwordManagerSeats: 5, + passwordManagerSeatTotal: 240, // 48 * 5 + secretsManagerSeatTotal: 360, // 72 * 5 + additionalStorageTotal: 6, // 6 * (2 - 1) + additionalStoragePriceMonthly: 6, + additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used) + totalAppliedDiscount: 0, + secretsManagerSubtotal: 360, // 0 + 360 + 0 + passwordManagerSubtotal: 246, // 0 + 240 + 6 + total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager + organization: mockOrganization, + sub: mockSub, + selectedPlan: mockPlan, + selectedInterval: PlanInterval.Monthly, + discountPercentageFromSub: 0, + discountPercentage: 20, + acceptingSponsorship: false, + additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0 + storageGb: 1, + isSecretsManagerTrial: false, + estimatedTax: 50, + }); + }); + + it("should calculate pricing data correctly with secrets manager enabled", async () => { + mockOrganization.useSecretsManager = true; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50) + }); + + it("should handle secrets manager trial", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + true, // isSecretsManagerTrial + 50, + ); + + expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial + expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial + }); + + it("should handle premium access option", async () => { + mockPlan.PasswordManager.hasPremiumAccessOption = true; + mockPlan.PasswordManager.premiumAccessOptionPrice = 25; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25 + }); + + it("should handle customer discount", async () => { + mockSub.customerDiscount = { + id: "discount1", + active: true, + percentOff: 10, + appliesTo: ["subscription"], + } as BillingCustomerDiscount; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.discountPercentageFromSub).toBe(10); + }); + + it("should handle zero storage calculation", async () => { + mockSub.maxStorageGb = 1; // Same as base storage + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.additionalStorageTotal).toBe(0); + expect(result.storageGb).toBe(0); + }); + }); + + describe("getAdditionalServiceAccount", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + + beforeEach(() => { + mockPlan = { + SecretsManager: { + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + mockSub = { + smServiceAccounts: 55, + } as OrganizationSubscriptionResponse; + }); + + it("should return additional service accounts when used exceeds base", () => { + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(5); // Math.abs(50 - 55) = 5 + }); + + it("should return 0 when used is less than or equal to base", () => { + mockSub.smServiceAccounts = 40; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when used equals base", () => { + mockSub.smServiceAccounts = 50; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan is null", () => { + const result = service.getAdditionalServiceAccount(null, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan has no SecretsManager", () => { + mockPlan.SecretsManager = null; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts index b3c071a8b8..da2fe0e8db 100644 --- a/apps/web/src/app/billing/services/pricing-summary.service.ts +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -31,9 +31,10 @@ export class PricingSummaryService { const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub); + const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb); + const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption - ? plan.PasswordManager.additionalStoragePricePerGb * - (sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0) + ? plan.PasswordManager.additionalStoragePricePerGb * storageGb : 0; const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0; @@ -66,7 +67,6 @@ export class PricingSummaryService { : (sub?.customerDiscount?.percentOff ?? 0); const discountPercentage = 20; const acceptingSponsorship = false; - const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; const total = organization?.useSecretsManager ? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index a4b2191562..1976321b4e 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -2,7 +2,7 @@ *ngIf="state === SetupExtensionState.Loading" class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted" aria-hidden="true" - [title]="'loading' | i18n" + [appA11yTitle]="'loading' | i18n" >
    diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 90468c61d5..582efade7f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3060,7 +3060,16 @@ "message": "Upgrade your account to a Premium membership and unlock some great additional features." }, "premiumSignUpStorage": { - "message": "1 GB encrypted storage for file attachments." + "message": "1 GB encrypted storage for file attachments." + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts index eed3db8739..4e128a7936 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts @@ -21,8 +21,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; -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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -100,19 +98,11 @@ export class ManageClientsComponent implements OnInit, OnDestroy { ), ); - protected providerPortalTakeover$ = this.configService.getFeatureFlag$( - FeatureFlag.PM21821_ProviderPortalTakeover, - ); - protected suspensionActive$ = combineLatest([ this.isAdminOrServiceUser$, - this.providerPortalTakeover$, this.provider$.pipe(map((provider) => provider?.enabled ?? false)), ]).pipe( - map( - ([isAdminOrServiceUser, portalTakeoverEnabled, providerEnabled]) => - isAdminOrServiceUser && portalTakeoverEnabled && !providerEnabled, - ), + map(([isAdminOrServiceUser, providerEnabled]) => isAdminOrServiceUser && !providerEnabled), ); private destroy$ = new Subject(); @@ -127,7 +117,6 @@ export class ManageClientsComponent implements OnInit, OnDestroy { private validationService: ValidationService, private webProviderService: WebProviderService, private billingNotificationService: BillingNotificationService, - private configService: ConfigService, private accountService: AccountService, private providerApiService: ProviderApiServiceAbstraction, ) {} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index ab4aaa6bd6..15536b22ae 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -12,7 +12,7 @@ route="clients" > ; protected clientsTranslationKey$: Observable; - protected providerPortalTakeover$: Observable; protected subscriber$: Observable; protected getTaxIdWarning$: () => Observable; @@ -56,7 +53,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, private providerService: ProviderService, - private configService: ConfigService, private providerWarningsService: ProviderWarningsService, private accountService: AccountService, ) {} @@ -101,10 +97,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { ) .subscribe(); - this.providerPortalTakeover$ = this.configService.getFeatureFlag$( - FeatureFlag.PM21821_ProviderPortalTakeover, - ); - this.subscriber$ = this.provider$.pipe( map((provider) => ({ type: "provider", diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts index 0eb25bff52..b3e4cd9bcc 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts @@ -5,7 +5,6 @@ import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ProviderId } from "@bitwarden/common/types/guid"; @@ -21,7 +20,6 @@ describe("ProviderWarningsService", () => { let service: ProviderWarningsService; let activatedRoute: MockProxy; let apiService: MockProxy; - let configService: MockProxy; let dialogService: MockProxy; let i18nService: MockProxy; let router: MockProxy; @@ -42,7 +40,6 @@ describe("ProviderWarningsService", () => { beforeEach(() => { activatedRoute = mock(); apiService = mock(); - configService = mock(); dialogService = mock(); i18nService = mock(); router = mock(); @@ -72,7 +69,6 @@ describe("ProviderWarningsService", () => { ProviderWarningsService, { provide: ActivatedRoute, useValue: activatedRoute }, { provide: ApiService, useValue: apiService }, - { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: Router, useValue: router }, @@ -211,22 +207,7 @@ describe("ProviderWarningsService", () => { }); describe("showProviderSuspendedDialog$", () => { - it("should not show dialog when feature flag is disabled", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - apiService.send.mockResolvedValue({ - Suspension: { Resolution: "add_payment_method" }, - }); - - service.showProviderSuspendedDialog$(provider).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - done(); - }, - }); - }); - it("should not show dialog when no suspension warning exists", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({}); service.showProviderSuspendedDialog$(provider).subscribe({ @@ -239,7 +220,6 @@ describe("ProviderWarningsService", () => { it("should show add payment method dialog with cancellation date", (done) => { const cancelsAt = new Date(2024, 11, 31); - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "add_payment_method", @@ -282,7 +262,6 @@ describe("ProviderWarningsService", () => { }); it("should show add payment method dialog without cancellation date", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "add_payment_method", @@ -319,7 +298,6 @@ describe("ProviderWarningsService", () => { }); it("should show contact administrator dialog for contact_administrator resolution", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "contact_administrator", @@ -343,7 +321,6 @@ describe("ProviderWarningsService", () => { }); it("should show contact support dialog with action for contact_support resolution", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "contact_support", diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts index 89ddf4b478..7ff36cc2db 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts @@ -2,7 +2,6 @@ import { Injectable } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, - combineLatest, from, lastValueFrom, map, @@ -16,8 +15,6 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -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 { SyncService } from "@bitwarden/common/platform/sync"; import { ProviderId } from "@bitwarden/common/types/guid"; @@ -39,7 +36,6 @@ export class ProviderWarningsService { constructor( private activatedRoute: ActivatedRoute, private apiService: ApiService, - private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private router: Router, @@ -61,12 +57,9 @@ export class ProviderWarningsService { refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); showProviderSuspendedDialog$ = (provider: Provider): Observable => - combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover), - this.getWarning$(provider, (response) => response.suspension), - ]).pipe( - switchMap(async ([providerPortalTakeover, warning]) => { - if (!providerPortalTakeover || !warning) { + this.getWarning$(provider, (response) => response.suspension).pipe( + switchMap(async (warning) => { + if (!warning) { return; } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index 107eb068e7..30a4d38b1d 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -40,6 +40,7 @@ describe("PremiumUpgradeDialogComponent", () => { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "feature1", value: "Feature 1" }, { key: "feature2", value: "Feature 2" }, @@ -58,6 +59,7 @@ describe("PremiumUpgradeDialogComponent", () => { users: 6, annualPrice: 40, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [{ key: "featureA", value: "Feature A" }], }, }; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index 7ba09192d3..7fd66878ca 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -31,6 +31,7 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "builtInAuthenticator", value: "Built-in authenticator" }, { key: "secureFileStorage", value: "Secure file storage" }, diff --git a/libs/angular/src/billing/components/premium.component.ts b/libs/angular/src/billing/components/premium.component.ts index 6d0b90385b..3f53d62e56 100644 --- a/libs/angular/src/billing/components/premium.component.ts +++ b/libs/angular/src/billing/components/premium.component.ts @@ -5,6 +5,7 @@ import { firstValueFrom, Observable, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -16,6 +17,7 @@ import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/com export class PremiumComponent implements OnInit { isPremium$: Observable; price = 10; + storageProvidedGb = 0; refreshPromise: Promise; cloudWebVaultUrl: string; @@ -29,6 +31,7 @@ export class PremiumComponent implements OnInit { billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, accountService: AccountService, + private billingApiService: BillingApiServiceAbstraction, ) { this.isPremium$ = accountService.activeAccount$.pipe( switchMap((account) => @@ -39,6 +42,9 @@ export class PremiumComponent implements OnInit { async ngOnInit() { this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); + const premiumResponse = await this.billingApiService.getPremiumPlan(); + this.storageProvidedGb = premiumResponse.storage.provided; + this.price = premiumResponse.seat.price; } async refresh() { diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts index 975e065e21..6fb355a8a1 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts @@ -71,7 +71,7 @@ describe("DefaultLoginSuccessHandlerService", () => { it("should log error and return early", async () => { await service.run(userId); - expect(logService.error).toHaveBeenCalledWith("SSO login email not found."); + expect(logService.debug).toHaveBeenCalledWith("SSO login email not found."); expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled(); }); }); diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts index 27d058c311..2b9672f1c0 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts @@ -25,7 +25,7 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer const ssoLoginEmail = await this.ssoLoginService.getSsoEmail(); if (!ssoLoginEmail) { - this.logService.error("SSO login email not found."); + this.logService.debug("SSO login email not found."); return; } diff --git a/libs/common/src/billing/models/response/premium-plan.response.ts b/libs/common/src/billing/models/response/premium-plan.response.ts index f5df560a60..73e4f834c6 100644 --- a/libs/common/src/billing/models/response/premium-plan.response.ts +++ b/libs/common/src/billing/models/response/premium-plan.response.ts @@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse { seat: { stripePriceId: string; price: number; + provided: number; }; storage: { stripePriceId: string; price: number; + provided: number; }; constructor(response: any) { @@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse { class PurchasableResponse extends BaseResponse { stripePriceId: string; price: number; + provided: number; constructor(response: any) { super(response); @@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse { if (typeof this.price !== "number" || isNaN(this.price)) { throw new Error("PurchasableResponse: Missing or invalid 'Price' property"); } + this.provided = this.getResponseProperty("Provided"); + if (typeof this.provided !== "number" || isNaN(this.provided)) { + throw new Error("PurchasableResponse: Missing or invalid 'Provided' property"); + } } } diff --git a/libs/common/src/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts index 8f5e9c0a3a..76e15c646a 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -55,6 +55,7 @@ describe("DefaultSubscriptionPricingService", () => { basePrice: 36, seatPrice: 0, additionalStoragePricePerGb: 4, + providedStorageGB: 1, allowSeatAutoscale: false, maxSeats: 6, maxCollections: null, @@ -94,6 +95,7 @@ describe("DefaultSubscriptionPricingService", () => { basePrice: 0, seatPrice: 36, additionalStoragePricePerGb: 4, + providedStorageGB: 1, allowSeatAutoscale: true, maxSeats: null, maxCollections: null, @@ -359,6 +361,7 @@ describe("DefaultSubscriptionPricingService", () => { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "builtInAuthenticator", value: "Built-in authenticator" }, { key: "secureFileStorage", value: "Secure file storage" }, @@ -383,6 +386,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPrice: mockFamiliesPlan.PasswordManager.basePrice, annualPricePerAdditionalStorageGB: mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockFamiliesPlan.PasswordManager.baseStorageGb, features: [ { key: "premiumAccounts", value: "6 premium accounts" }, { key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" }, @@ -456,6 +460,7 @@ describe("DefaultSubscriptionPricingService", () => { expect(premiumTier.passwordManager.annualPrice).toEqual(10); expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4); + expect(premiumTier.passwordManager.providedStorageGB).toEqual(1); expect(familiesTier.passwordManager.annualPrice).toEqual( mockFamiliesPlan.PasswordManager.basePrice, @@ -463,6 +468,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual( mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, ); + expect(familiesTier.passwordManager.providedStorageGB).toEqual( + mockFamiliesPlan.PasswordManager.baseStorageGb, + ); done(); }); @@ -487,6 +495,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb, features: [ { key: "secureItemSharing", value: "Secure item sharing" }, { key: "eventLogMonitoring", value: "Event log monitoring" }, @@ -522,6 +531,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb, features: [ { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, { key: "passwordLessSso", value: "Passwordless SSO" }, @@ -648,6 +658,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual( mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount, ); + expect(teamsPasswordManager.providedStorageGB).toEqual( + mockTeamsPlan.PasswordManager.baseStorageGb, + ); const enterprisePasswordManager = enterpriseTier.passwordManager as any; const enterpriseSecretsManager = enterpriseTier.secretsManager as any; @@ -657,6 +670,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual( mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, ); + expect(enterprisePasswordManager.providedStorageGB).toEqual( + mockEnterprisePlan.PasswordManager.baseStorageGb, + ); expect(enterpriseSecretsManager.annualPricePerUser).toEqual( mockEnterprisePlan.SecretsManager.seatPrice, ); @@ -729,6 +745,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb, features: [ { key: "secureItemSharing", value: "Secure item sharing" }, { key: "eventLogMonitoring", value: "Event log monitoring" }, @@ -764,6 +781,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb, features: [ { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, { key: "passwordLessSso", value: "Passwordless SSO" }, diff --git a/libs/common/src/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts index f1502eb26e..a3f048fee7 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -40,6 +40,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer */ private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10; private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4; + private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1; constructor( private billingApiService: BillingApiServiceAbstraction, @@ -114,11 +115,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer map((premiumPlan) => ({ seat: premiumPlan.seat.price, storage: premiumPlan.storage.price, + provided: premiumPlan.storage.provided, })), ) : of({ seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB, }), ), map((premiumPrices) => ({ @@ -130,6 +133,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer type: "standalone", annualPrice: premiumPrices.seat, annualPricePerAdditionalStorageGB: premiumPrices.storage, + providedStorageGB: premiumPrices.provided, features: [ this.featureTranslations.builtInAuthenticator(), this.featureTranslations.secureFileStorage(), @@ -161,6 +165,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPrice: familiesPlan.PasswordManager.basePrice, annualPricePerAdditionalStorageGB: familiesPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: familiesPlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.premiumAccounts(), this.featureTranslations.familiesUnlimitedSharing(), @@ -214,6 +219,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: annualTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.secureItemSharing(), this.featureTranslations.eventLogMonitoring(), @@ -253,6 +259,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.enterpriseSecurityPolicies(), this.featureTranslations.passwordLessSso(), diff --git a/libs/common/src/billing/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts index 8febc4b86d..3f5c076ba4 100644 --- a/libs/common/src/billing/types/subscription-pricing-tier.ts +++ b/libs/common/src/billing/types/subscription-pricing-tier.ts @@ -30,13 +30,19 @@ type HasAdditionalStorage = { annualPricePerAdditionalStorageGB: number; }; +type HasProvidedStorage = { + providedStorageGB: number; +}; + type StandalonePasswordManager = HasFeatures & - HasAdditionalStorage & { + HasAdditionalStorage & + HasProvidedStorage & { type: "standalone"; annualPrice: number; }; type PackagedPasswordManager = HasFeatures & + HasProvidedStorage & HasAdditionalStorage & { type: "packaged"; users: number; @@ -52,6 +58,7 @@ type CustomPasswordManager = HasFeatures & { }; type ScalablePasswordManager = HasFeatures & + HasProvidedStorage & HasAdditionalStorage & { type: "scalable"; annualPricePerUser: number; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 6010110f06..01ffdafcef 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -24,7 +24,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", @@ -126,7 +125,6 @@ export const DefaultFeatureFlagValue = { /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 6057a91bae..fdd42c0acf 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -67,7 +67,10 @@ export abstract class CipherEncryptionService { * * @returns A promise that resolves to an array of decrypted cipher views */ - abstract decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise; + abstract decryptManyLegacy( + ciphers: Cipher[], + userId: UserId, + ): Promise<[CipherView[], CipherView[]]>; /** * Decrypts many ciphers using the SDK for the given userId, and returns a list of * failures. diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 0d4ab8e520..db360f7f99 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -166,10 +166,6 @@ export class CipherView implements View, InitializerMetadata { } get canAssignToCollections(): boolean { - if (this.isArchived) { - return false; - } - if (this.organizationId == null) { return true; } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 85ce8bd042..fe2926144b 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -807,7 +807,7 @@ describe("Cipher Service", () => { // Set up expected results const expectedSuccessCipherViews = [ - { id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView, + { id: mockCiphers[0].id, name: "Success 1", decryptionFailure: false } as CipherView, ]; const expectedFailedCipher = new CipherView(mockCiphers[1]); @@ -815,6 +815,11 @@ describe("Cipher Service", () => { expectedFailedCipher.decryptionFailure = true; const expectedFailedCipherViews = [expectedFailedCipher]; + cipherEncryptionService.decryptManyLegacy.mockResolvedValue([ + expectedSuccessCipherViews, + expectedFailedCipherViews, + ]); + // Execute const [successes, failures] = await (cipherService as any).decryptCiphers( mockCiphers, @@ -822,10 +827,7 @@ describe("Cipher Service", () => { ); // Verify the SDK was used for decryption - expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledWith( - mockCiphers, - userId, - ); + expect(cipherEncryptionService.decryptManyLegacy).toHaveBeenCalledWith(mockCiphers, userId); expect(successes).toEqual(expectedSuccessCipherViews); expect(failures).toEqual(expectedFailedCipherViews); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 72c1ca4091..b2c5ac8943 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -2143,15 +2143,19 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, fullDecryption: boolean = true, ): Promise<[CipherViewLike[], CipherView[]]> { + if (fullDecryption) { + const [decryptedViews, failedViews] = await this.cipherEncryptionService.decryptManyLegacy( + ciphers, + userId, + ); + return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews]; + } + const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures( ciphers, userId, ); - const decryptedViews = fullDecryption - ? await Promise.all(decrypted.map((c) => this.getFullCipherView(c))) - : decrypted; - const failedViews = failures.map((c) => { const cipher_view = new CipherView(c); cipher_view.name = "[error: cannot decrypt]"; @@ -2159,7 +2163,7 @@ export class CipherService implements CipherServiceAbstraction { return cipher_view; }); - return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews]; + return [decrypted.sort(this.getLocaleSortingFunction()), failedViews]; } /** Fetches the full `CipherView` when a `CipherListView` is passed. */ diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 6d6341bd1f..f54dfa17a3 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -496,9 +496,11 @@ describe("DefaultCipherEncryptionService", () => { .mockReturnValueOnce(expectedViews[0]) .mockReturnValueOnce(expectedViews[1]); - const result = await cipherEncryptionService.decryptManyLegacy(ciphers, userId); + const [successfulDecryptions, failedDecryptions] = + await cipherEncryptionService.decryptManyLegacy(ciphers, userId); - expect(result).toEqual(expectedViews); + expect(successfulDecryptions).toEqual(expectedViews); + expect(failedDecryptions).toEqual([]); expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledTimes(2); expect(CipherView.fromSdkCipherView).toHaveBeenCalledTimes(2); }); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 3f03e0f5e9..f1b737ed50 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -168,7 +168,7 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ); } - decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise { + decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<[CipherView[], CipherView[]]> { return firstValueFrom( this.sdkService.userClient$(userId).pipe( map((sdk) => { @@ -178,38 +178,49 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { using ref = sdk.take(); - return ciphers.map((cipher) => { - const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); - const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; + const successful: CipherView[] = []; + const failed: CipherView[] = []; - // Handle FIDO2 credentials if present - if ( - clientCipherView.type === CipherType.Login && - sdkCipherView.login?.fido2Credentials?.length - ) { - const fido2CredentialViews = ref.value - .vault() - .ciphers() - .decrypt_fido2_credentials(sdkCipherView); + ciphers.forEach((cipher) => { + try { + const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); + const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; - // TODO (PM-21259): Remove manual keyValue decryption for FIDO2 credentials. - // This is a temporary workaround until we can use the SDK for FIDO2 authentication. - const decryptedKeyValue = ref.value - .vault() - .ciphers() - .decrypt_fido2_private_key(sdkCipherView); + // Handle FIDO2 credentials if present + if ( + clientCipherView.type === CipherType.Login && + sdkCipherView.login?.fido2Credentials?.length + ) { + const fido2CredentialViews = ref.value + .vault() + .ciphers() + .decrypt_fido2_credentials(sdkCipherView); - clientCipherView.login.fido2Credentials = fido2CredentialViews - .map((f) => { - const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - view.keyValue = decryptedKeyValue; - return view; - }) - .filter((view): view is Fido2CredentialView => view !== undefined); + const decryptedKeyValue = ref.value + .vault() + .ciphers() + .decrypt_fido2_private_key(sdkCipherView); + + clientCipherView.login.fido2Credentials = fido2CredentialViews + .map((f) => { + const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; + view.keyValue = decryptedKeyValue; + return view; + }) + .filter((view): view is Fido2CredentialView => view !== undefined); + } + + successful.push(clientCipherView); + } catch (error) { + this.logService.error(`Failed to decrypt cipher ${cipher.id}: ${error}`); + const failedView = new CipherView(cipher); + failedView.name = "[error: cannot decrypt]"; + failedView.decryptionFailure = true; + failed.push(failedView); } - - return clientCipherView; }); + + return [successful, failed] as [CipherView[], CipherView[]]; }), catchError((error: unknown) => { this.logService.error(`Failed to decrypt ciphers: ${error}`); diff --git a/libs/components/src/a11y/a11y-title.directive.ts b/libs/components/src/a11y/a11y-title.directive.ts index fa038172cb..75c2967805 100644 --- a/libs/components/src/a11y/a11y-title.directive.ts +++ b/libs/components/src/a11y/a11y-title.directive.ts @@ -1,21 +1,23 @@ -import { Directive } from "@angular/core"; +import { Directive, effect, ElementRef, input } from "@angular/core"; -import { TooltipDirective } from "../tooltip/tooltip.directive"; +import { setA11yTitleAndAriaLabel } from "./set-a11y-title-and-aria-label"; -/** - * @deprecated This function is deprecated in favor of `bitTooltip`. - * Please use `bitTooltip` instead. - * - * Directive that provides accessible tooltips by internally using TooltipDirective. - * This maintains the appA11yTitle API while leveraging the enhanced tooltip functionality. - */ @Directive({ selector: "[appA11yTitle]", - hostDirectives: [ - { - directive: TooltipDirective, - inputs: ["bitTooltip: appA11yTitle", "tooltipPosition"], - }, - ], }) -export class A11yTitleDirective {} +export class A11yTitleDirective { + readonly title = input.required({ alias: "appA11yTitle" }); + + constructor(private el: ElementRef) { + const originalTitle = this.el.nativeElement.getAttribute("title"); + const originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); + + effect(() => { + setA11yTitleAndAriaLabel({ + element: this.el.nativeElement, + title: originalTitle ?? this.title(), + label: originalAriaLabel ?? this.title(), + }); + }); + } +} diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index 12be865243..cca52526c7 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -17,6 +17,7 @@ import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component"; export const TOOLTIP_DELAY_MS = 800; + /** * Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input. * The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center". diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 6fd74d8652..58959a957a 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -192,11 +192,6 @@ export class ItemDetailsSectionComponent implements OnInit { } get showOwnership() { - // Don't show ownership field for archived ciphers - if (this.originalCipherView?.isArchived) { - return false; - } - // Show ownership field when editing with available orgs const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit"; diff --git a/package-lock.json b/package-lock.json index f362975c92..ea662c62b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.18", + "tldts": "7.0.19", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -161,7 +161,7 @@ "postcss": "8.5.6", "postcss-loader": "8.2.0", "prettier": "3.6.2", - "prettier-plugin-tailwindcss": "0.6.11", + "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.0.1", @@ -224,7 +224,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.18", + "tldts": "7.0.19", "zxcvbn": "4.4.2" }, "bin": { @@ -292,7 +292,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.1" + "version": "2025.12.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -34470,16 +34470,18 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz", + "integrity": "sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=20.19" }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", @@ -34487,20 +34489,24 @@ "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "peerDependenciesMeta": { "@ianvs/prettier-plugin-sort-imports": { "optional": true }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, "@prettier/plugin-pug": { "optional": true }, @@ -34519,9 +34525,6 @@ "prettier-plugin-css-order": { "optional": true }, - "prettier-plugin-import-sort": { - "optional": true - }, "prettier-plugin-jsdoc": { "optional": true }, @@ -34540,9 +34543,6 @@ "prettier-plugin-sort-imports": { "optional": true }, - "prettier-plugin-style-order": { - "optional": true - }, "prettier-plugin-svelte": { "optional": true } @@ -38727,12 +38727,12 @@ } }, "node_modules/tldts": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", - "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.18" + "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" diff --git a/package.json b/package.json index be4d25ec49..ab83b981b6 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "postcss": "8.5.6", "postcss-loader": "8.2.0", "prettier": "3.6.2", - "prettier-plugin-tailwindcss": "0.6.11", + "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.0.1", @@ -202,7 +202,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.18", + "tldts": "7.0.19", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4",