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

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

View File

@@ -28,7 +28,7 @@ const preview: Preview = {
],
parameters: {
a11y: {
element: "#storybook-root",
context: "#storybook-root",
},
controls: {
matchers: {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -166,10 +166,6 @@ export class CipherView implements View, InitializerMetadata {
}
get canAssignToCollections(): boolean {
if (this.isArchived) {
return false;
}
if (this.organizationId == null) {
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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