mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build
This commit is contained in:
6
.github/workflows/repository-management.yml
vendored
6
.github/workflows/repository-management.yml
vendored
@@ -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
|
||||
|
||||
@@ -28,7 +28,7 @@ const preview: Preview = {
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
context: "#storybook-root",
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -69,8 +69,8 @@ export type FieldRect = {
|
||||
};
|
||||
|
||||
export type InlineMenuPosition = {
|
||||
button?: InlineMenuElementPosition;
|
||||
list?: InlineMenuElementPosition;
|
||||
button?: InlineMenuElementPosition | null;
|
||||
list?: InlineMenuElementPosition | null;
|
||||
};
|
||||
|
||||
export type NewLoginCipherData = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails &
|
||||
matches: string[];
|
||||
excludeMatches: string[];
|
||||
allFrames: true;
|
||||
world?: "MAIN" | "ISOLATED";
|
||||
};
|
||||
|
||||
type Fido2ExtensionMessage = {
|
||||
|
||||
@@ -203,7 +203,6 @@ describe("Fido2Background", () => {
|
||||
{ file: Fido2ContentScript.PageScriptDelayAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "ISOLATED",
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
{ file: await this.getFido2PageScriptAppendFileName() },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "ISOLATED",
|
||||
...this.sharedRegistrationOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<MediaQueryList>({ 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<MediaQueryList>({ 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "ppremiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div id="sends" class="vault">
|
||||
<div id="items" class="items">
|
||||
<div class="content">
|
||||
<div class="list full-height" *ngIf="filteredSends && filteredSends.length">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let s of filteredSends"
|
||||
appStopClick
|
||||
(click)="selectSend(s.id)"
|
||||
title="{{ 'viewItem' | i18n }}"
|
||||
(contextmenu)="viewSendMenu(s)"
|
||||
[ngClass]="{ active: s.id === sendId }"
|
||||
[attr.aria-pressed]="s.id === sendId"
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-badges">
|
||||
<ng-container *ngIf="s.disabled">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'disabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.password">
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.expired">
|
||||
<i
|
||||
class="bwi bwi-clock"
|
||||
appStopProp
|
||||
title="{{ 'expired' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "expired" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.pendingDelete">
|
||||
<i
|
||||
class="bwi bwi-trash"
|
||||
appStopProp
|
||||
title="{{ 'pendingDeletion' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-details">{{ s.deletionDate | date }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="no-items" *ngIf="!filteredSends || !filteredSends.length">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" *ngIf="!loaded" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="loaded">
|
||||
<img class="no-items-image" aria-hidden="true" />
|
||||
<p>{{ "noItemsInList" | i18n }}</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSend()"
|
||||
class="block primary"
|
||||
appA11yTitle="{{ 'addItem' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
*ngIf="action == 'add' || action == 'edit'"
|
||||
[sendId]="sendId"
|
||||
[type]="selectedSendType"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="cancel($event)"
|
||||
(onDeletedSend)="deletedSend($event)"
|
||||
></app-send-add-edit>
|
||||
<div class="logo" *ngIf="!action">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<SendV2Component>;
|
||||
let sendService: MockProxy<SendService>;
|
||||
let searchBarService: MockProxy<SearchBarService>;
|
||||
let broadcasterService: MockProxy<BroadcasterService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sendService = mock<SendService>();
|
||||
searchBarService = mock<SearchBarService>();
|
||||
broadcasterService = mock<BroadcasterService>();
|
||||
accountService = mock<AccountService>();
|
||||
policyService = mock<PolicyService>();
|
||||
|
||||
// Mock sendViews$ observable
|
||||
sendService.sendViews$ = of([]);
|
||||
searchBarService.searchText$ = new BehaviorSubject<string>("");
|
||||
|
||||
// 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<I18nService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: BroadcasterService, useValue: broadcasterService },
|
||||
{ provide: SearchService, useValue: mock<SearchService>() },
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: SearchBarService, useValue: searchBarService },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: SendApiService, useValue: mock<SendApiService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ 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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<string>("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "<p>Sends V2 Component</p>",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-li bwi-check text-success" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-li bwi-check text-success" aria-hidden="true"></i>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
70
apps/desktop/src/vault/app/vault-v3/vault.component.html
Normal file
70
apps/desktop/src/vault/app/vault-v3/vault.component.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<div id="vault" class="vault vault-v2" attr.aria-hidden="{{ showingModal }}">
|
||||
<app-vault-items-v2
|
||||
id="items"
|
||||
class="items"
|
||||
[activeCipherId]="cipherId"
|
||||
(onCipherClicked)="viewCipher($event)"
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
<div class="details" *ngIf="!!action">
|
||||
<app-vault-item-footer
|
||||
id="footer"
|
||||
#footer
|
||||
[cipher]="cipher"
|
||||
[action]="action"
|
||||
(onEdit)="editCipher($event)"
|
||||
(onRestore)="restoreCipher()"
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<div class="box">
|
||||
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
|
||||
</app-cipher-view>
|
||||
<vault-cipher-form
|
||||
#vaultForm
|
||||
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
|
||||
formId="cipherForm"
|
||||
[config]="config"
|
||||
(cipherSaved)="savedCipher($event)"
|
||||
[submitBtn]="footer?.submitBtn"
|
||||
(formStatusChange$)="formStatusChanged($event)"
|
||||
>
|
||||
<bit-item slot="attachment-button">
|
||||
<button
|
||||
bit-item-content
|
||||
type="button"
|
||||
(click)="openAttachmentsDialog()"
|
||||
[disabled]="formDisabled"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "attachments" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
</vault-cipher-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="logo"
|
||||
class="logo"
|
||||
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #folderAddEdit></ng-template>
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { VaultComponent } from "./vault.component";
|
||||
|
||||
describe("VaultComponent", () => {
|
||||
let component: VaultComponent;
|
||||
let fixture: ComponentFixture<VaultComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
@@ -82,7 +82,10 @@
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||
| i18n
|
||||
: `${(providedStorageGb$ | async)} GB`
|
||||
: (storagePrice$ | async | currency: "$")
|
||||
: ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
|
||||
{{
|
||||
"gbEncryptedFileStorage"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + " GB"
|
||||
}}
|
||||
</li>
|
||||
<li *ngIf="selectableProduct.hasGroups">
|
||||
@@ -239,7 +239,7 @@
|
||||
<bit-hint class="tw-text-sm">{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n
|
||||
: "1 GB"
|
||||
: `${selectedPlan.PasswordManager.baseStorageGb} GB`
|
||||
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
|
||||
: ("month" | i18n)
|
||||
}}</bit-hint>
|
||||
|
||||
@@ -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<string> => {
|
||||
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,
|
||||
|
||||
@@ -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<PricingSummaryData>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
></i>
|
||||
|
||||
<section *ngIf="state === SetupExtensionState.NeedsExtension" class="tw-text-center tw-mt-4">
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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<void>();
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
route="clients"
|
||||
>
|
||||
<i
|
||||
*ngIf="!provider.enabled && (providerPortalTakeover$ | async)"
|
||||
*ngIf="!provider.enabled"
|
||||
slot="end"
|
||||
class="bwi bwi-exclamation-triangle tw-text-danger"
|
||||
title="{{ 'providerIsDisabled' | i18n }}"
|
||||
|
||||
@@ -13,8 +13,6 @@ import { ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components";
|
||||
@@ -48,7 +46,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected clientsTranslationKey$: Observable<string>;
|
||||
protected providerPortalTakeover$: Observable<boolean>;
|
||||
|
||||
protected subscriber$: Observable<NonIndividualSubscriber>;
|
||||
protected getTaxIdWarning$: () => Observable<TaxIdWarningType>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ActivatedRoute>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let router: MockProxy<Router>;
|
||||
@@ -42,7 +40,6 @@ describe("ProviderWarningsService", () => {
|
||||
beforeEach(() => {
|
||||
activatedRoute = mock<ActivatedRoute>();
|
||||
apiService = mock<ApiService>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
router = mock<Router>();
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> =>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<boolean>;
|
||||
price = 10;
|
||||
storageProvidedGb = 0;
|
||||
refreshPromise: Promise<any>;
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CipherView[]>;
|
||||
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.
|
||||
|
||||
@@ -166,10 +166,6 @@ export class CipherView implements View, InitializerMetadata {
|
||||
}
|
||||
|
||||
get canAssignToCollections(): boolean {
|
||||
if (this.isArchived) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.organizationId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
);
|
||||
}
|
||||
|
||||
decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<CipherView[]> {
|
||||
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}`);
|
||||
|
||||
@@ -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<string>({ 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user