1
0
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:
John Harrington
2025-12-02 14:56:05 -07:00
committed by GitHub
62 changed files with 2573 additions and 273 deletions

View File

@@ -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."
},

View File

@@ -69,8 +69,8 @@ export type FieldRect = {
};
export type InlineMenuPosition = {
button?: InlineMenuElementPosition;
list?: InlineMenuElementPosition;
button?: InlineMenuElementPosition | null;
list?: InlineMenuElementPosition | null;
};
export type NewLoginCipherData = {

View File

@@ -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) =>

View File

@@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails &
matches: string[];
excludeMatches: string[];
allFrames: true;
world?: "MAIN" | "ISOLATED";
};
type Fido2ExtensionMessage = {

View File

@@ -203,7 +203,6 @@ describe("Fido2Background", () => {
{ file: Fido2ContentScript.PageScriptDelayAppend },
{ file: Fido2ContentScript.ContentScript },
],
world: "ISOLATED",
...sharedRegistrationOptions,
});
});

View File

@@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface {
{ file: await this.getFido2PageScriptAppendFileName() },
{ file: Fido2ContentScript.ContentScript },
],
world: "ISOLATED",
...this.sharedRegistrationOptions,
});
}

View File

@@ -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}`);
});
});

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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 }}

View File

@@ -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);
}

View File

@@ -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"
}
}

View File

@@ -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;

View 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>

View File

@@ -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();
});
});
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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,
);
}
}

View File

@@ -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."
},

View 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>

View File

@@ -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

View File

@@ -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",

View File

@@ -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>

View File

@@ -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) =>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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

View File

@@ -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">

View File

@@ -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."