mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 11:13:44 +00:00
Merge branch 'main' into ps/PM-14166-add-brave-vivaldi
This commit is contained in:
10
.github/renovate.json5
vendored
10
.github/renovate.json5
vendored
@@ -119,7 +119,7 @@
|
||||
"rimraf",
|
||||
"ssh-encoding",
|
||||
"ssh-key",
|
||||
"@storybook/web-components-webpack5",
|
||||
"@storybook/web-components-vite",
|
||||
"tabbable",
|
||||
"tldts",
|
||||
"wait-on",
|
||||
@@ -311,26 +311,24 @@
|
||||
"@compodoc/compodoc",
|
||||
"@ng-select/ng-select",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-actions",
|
||||
"@storybook/addon-designs",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/test-runner",
|
||||
"@storybook/addon-themes",
|
||||
"@storybook/angular",
|
||||
"@storybook/manager-api",
|
||||
"@storybook/theming",
|
||||
"@types/react",
|
||||
"autoprefixer",
|
||||
"bootstrap",
|
||||
"chromatic",
|
||||
"ngx-toastr",
|
||||
"path-browserify",
|
||||
"react",
|
||||
"react-dom",
|
||||
"remark-gfm",
|
||||
"storybook",
|
||||
"tailwindcss",
|
||||
"vite-tsconfig-paths",
|
||||
"zone.js",
|
||||
"@tailwindcss/container-queries",
|
||||
],
|
||||
|
||||
14
.github/workflows/build-desktop.yml
vendored
14
.github/workflows/build-desktop.yml
vendored
@@ -186,7 +186,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -490,7 +490,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -756,7 +756,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1007,7 +1007,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1244,7 +1244,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1516,7 +1516,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
|
||||
6
.github/workflows/repository-management.yml
vendored
6
.github/workflows/repository-management.yml
vendored
@@ -29,7 +29,7 @@ on:
|
||||
default: false
|
||||
target_ref:
|
||||
default: "main"
|
||||
description: "Branch/Tag to target for cut"
|
||||
description: "Branch/Tag to target for cut (ignored if not cutting rc)"
|
||||
required: true
|
||||
type: string
|
||||
version_number_override:
|
||||
@@ -102,11 +102,12 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for committing and pushing to current branch
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
|
||||
@@ -467,6 +468,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for creating and pushing new branch
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -148,7 +148,7 @@ jobs:
|
||||
components: llvm-tools
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: "apps/desktop/desktop_native -> target"
|
||||
|
||||
|
||||
@@ -28,15 +28,13 @@ const config: StorybookConfig = {
|
||||
],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-designs"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-themes"),
|
||||
{
|
||||
// @storybook/addon-docs is part of @storybook/addon-essentials
|
||||
// eslint-disable-next-line storybook/no-uninstalled-addons
|
||||
name: "@storybook/addon-docs",
|
||||
|
||||
name: getAbsolutePath("@storybook/addon-docs"),
|
||||
options: {
|
||||
mdxPluginOptions: {
|
||||
mdxCompileOptions: {
|
||||
@@ -60,6 +58,10 @@ const config: StorybookConfig = {
|
||||
webpackFinal: async (config, { configType }) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.plugins = [new TsconfigPathsPlugin()] as any;
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
path: require.resolve("path-browserify"),
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { addons } from "@storybook/manager-api";
|
||||
import { create } from "@storybook/theming/create";
|
||||
import { addons } from "storybook/manager-api";
|
||||
import { create } from "storybook/theming";
|
||||
|
||||
const lightTheme = create({
|
||||
base: "light",
|
||||
|
||||
@@ -28,7 +28,7 @@ const preview: Preview = {
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
context: "#storybook-root",
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
@@ -49,7 +49,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
disable: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
|
||||
26
angular.json
26
angular.json
@@ -220,5 +220,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,6 +1475,15 @@
|
||||
"ppremiumSignUpStorage": {
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
},
|
||||
"premiumSignUpStorageV2": {
|
||||
"message": "$SIZE$ encrypted storage for file attachments.",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"content": "$1",
|
||||
"example": "1 GB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSignUpEmergency": {
|
||||
"message": "Emergency access."
|
||||
},
|
||||
|
||||
@@ -69,8 +69,8 @@ export type FieldRect = {
|
||||
};
|
||||
|
||||
export type InlineMenuPosition = {
|
||||
button?: InlineMenuElementPosition;
|
||||
list?: InlineMenuElementPosition;
|
||||
button?: InlineMenuElementPosition | null;
|
||||
list?: InlineMenuElementPosition | null;
|
||||
};
|
||||
|
||||
export type NewLoginCipherData = {
|
||||
|
||||
@@ -1424,11 +1424,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates the postion and width for multi-input totp field inline menu
|
||||
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
|
||||
* calculates the position and width for multi-input TOTP field inline menu
|
||||
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
|
||||
*/
|
||||
private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) {
|
||||
// Filter the fields based on the provided totpfields
|
||||
// Filter the fields based on the provided TOTP fields
|
||||
const filteredObjects = this.allFieldData.filter((obj) =>
|
||||
totpFieldArray.some((o) => o.opid === obj.opid),
|
||||
);
|
||||
@@ -1451,8 +1451,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates the postion for multi-input totp field inline button
|
||||
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
|
||||
* calculates the position for multi-input TOTP field inline button
|
||||
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
|
||||
*/
|
||||
private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) {
|
||||
const filteredObjects = this.allFieldData.filter((obj) =>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
import type { StorybookConfig } from "@storybook/web-components-webpack5";
|
||||
import type { StorybookConfig } from "@storybook/web-components-vite";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDirectory = dirname(currentFile);
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -18,10 +14,8 @@ const config: StorybookConfig = {
|
||||
stories: ["../lit-stories/**/*.lit-stories.@(js|jsx|ts|tsx)", "../lit-stories/**/*.mdx"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-designs"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
{
|
||||
name: "@storybook/addon-docs",
|
||||
options: {
|
||||
@@ -34,10 +28,8 @@ const config: StorybookConfig = {
|
||||
},
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/web-components-webpack5"),
|
||||
options: {
|
||||
legacyRootApi: true,
|
||||
},
|
||||
name: getAbsolutePath("@storybook/web-components-vite"),
|
||||
options: {},
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
@@ -46,33 +38,12 @@ const config: StorybookConfig = {
|
||||
...existingConfig,
|
||||
FLAGS: JSON.stringify({}),
|
||||
}),
|
||||
webpackFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.plugins = [
|
||||
new TsconfigPathsPlugin({
|
||||
configFile: resolve(currentDirectory, "../../../../../tsconfig.json"),
|
||||
}),
|
||||
] as any;
|
||||
}
|
||||
|
||||
if (config.module && config.module.rules) {
|
||||
config.module.rules.push({
|
||||
test: /\.(ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve("ts-loader"),
|
||||
},
|
||||
],
|
||||
});
|
||||
config.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
use: [require.resolve("css-loader"), require.resolve("sass-loader")],
|
||||
});
|
||||
}
|
||||
return config;
|
||||
viteFinal: async (config) => {
|
||||
return {
|
||||
...config,
|
||||
plugins: [...(config.plugins ?? []), tsconfigPaths()],
|
||||
};
|
||||
},
|
||||
docs: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./action-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./badge-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./body.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./close-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./edit-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./footer.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./header.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./icons.lit-stories";
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails &
|
||||
matches: string[];
|
||||
excludeMatches: string[];
|
||||
allFrames: true;
|
||||
world?: "MAIN" | "ISOLATED";
|
||||
};
|
||||
|
||||
type Fido2ExtensionMessage = {
|
||||
|
||||
@@ -203,7 +203,6 @@ describe("Fido2Background", () => {
|
||||
{ file: Fido2ContentScript.PageScriptDelayAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "ISOLATED",
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
{ file: await this.getFido2PageScriptAppendFileName() },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "ISOLATED",
|
||||
...this.sharedRegistrationOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,48 +29,38 @@ describe("FIDO2 page-script for manifest v2", () => {
|
||||
expect(window.document.createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", async () => {
|
||||
const scriptContents = "test-script-contents";
|
||||
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => {
|
||||
jest.spyOn(window.document.head, "prepend").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
window.fetch = jest.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve(scriptContents),
|
||||
} as Response);
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./fido2-page-script-delay-append.mv2.ts");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.head.prepend).toHaveBeenCalledWith(expect.any(HTMLScriptElement));
|
||||
expect(createdScriptElement.innerHTML).toBe(scriptContents);
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document element if the head is not available", async () => {
|
||||
const scriptContents = "test-script-contents";
|
||||
it("appends the `page-script.js` file to the document element if the head is not available", () => {
|
||||
window.document.documentElement.removeChild(window.document.head);
|
||||
jest.spyOn(window.document.documentElement, "prepend").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
window.fetch = jest.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve(scriptContents),
|
||||
} as Response);
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./fido2-page-script-delay-append.mv2.ts");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.documentElement.prepend).toHaveBeenCalledWith(
|
||||
expect.any(HTMLScriptElement),
|
||||
);
|
||||
expect(createdScriptElement.innerHTML).toBe(scriptContents);
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,26 +2,17 @@
|
||||
* This script handles injection of the FIDO2 override page script into the document.
|
||||
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
|
||||
*/
|
||||
void (async function (globalContext) {
|
||||
(function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = globalContext.document.createElement("script");
|
||||
// We're removing stack trace information in the page script instead
|
||||
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
|
||||
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
script.async = false;
|
||||
|
||||
const pageScriptUrl = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
// Inject the script contents directly to avoid leaking the extension URL
|
||||
try {
|
||||
const response = await fetch(pageScriptUrl);
|
||||
const scriptContents = await response.text();
|
||||
script.innerHTML = scriptContents;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load FIDO2 page script contents. Injection failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// We are ensuring that the script injection is delayed in the event that we are loading
|
||||
// within an iframe element. This prevents an issue with web mail clients that load content
|
||||
// using ajax within iframes. In particular, Zimbra web mail client was observed to have this issue.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
import { createPortSpyMock } from "../../../spec/autofill-mocks";
|
||||
@@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked
|
||||
it.skip("creates an aria alert element if the ariaAlert param is passed", () => {
|
||||
const ariaAlert = "aria alert";
|
||||
it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement");
|
||||
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
|
||||
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith(
|
||||
ariaAlert,
|
||||
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe(
|
||||
"alert",
|
||||
);
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe(
|
||||
"polite",
|
||||
);
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => {
|
||||
const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" });
|
||||
const serviceWithoutAlert = new AutofillInlineMenuIframeService(
|
||||
shadowWithoutAlert,
|
||||
AutofillOverlayPort.Button,
|
||||
{ height: "0px" },
|
||||
"title",
|
||||
);
|
||||
jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement");
|
||||
|
||||
serviceWithoutAlert.initMenuIframe();
|
||||
|
||||
expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled();
|
||||
expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("on load of the iframe source", () => {
|
||||
@@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" });
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -216,7 +237,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
|
||||
expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey);
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
|
||||
});
|
||||
});
|
||||
@@ -234,14 +255,14 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
it("passes the message on to the iframe element", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
theme: ThemeTypes.Light,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
|
||||
});
|
||||
|
||||
@@ -249,18 +270,18 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
theme: ThemeTypes.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
theme: ThemeTypes.Light,
|
||||
},
|
||||
autofillInlineMenuIframeService["extensionOrigin"],
|
||||
);
|
||||
@@ -270,18 +291,18 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
theme: ThemeTypes.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
theme: ThemeTypes.Dark,
|
||||
},
|
||||
autofillInlineMenuIframeService["extensionOrigin"],
|
||||
);
|
||||
@@ -290,7 +311,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
it("updates the border to match the `dark` theme", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
theme: ThemeTypes.Dark,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
@@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"],
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (bottom)", () => {
|
||||
const viewportHeight = 800;
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: viewportHeight + 1,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: viewportHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (right)", () => {
|
||||
const viewportWidth = 1200;
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: viewportWidth + 1,
|
||||
bottom: 100,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: viewportWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (left)", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: -1,
|
||||
right: 0,
|
||||
bottom: 100,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (top)", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: -1,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 0,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows iframe (do not close) when it has no dimensions", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
} as DOMRect);
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses visualViewport when available", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 700,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
|
||||
Object.defineProperty(globalThis.window, "visualViewport", {
|
||||
value: {
|
||||
height: 600,
|
||||
width: 1200,
|
||||
} as VisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the visibility of the iframe", () => {
|
||||
@@ -381,7 +615,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
|
||||
@@ -282,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
|
||||
const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position;
|
||||
this.updateElementStyles(this.iframe, styles);
|
||||
|
||||
const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport(
|
||||
this.iframe.getBoundingClientRect(),
|
||||
);
|
||||
|
||||
if (!elementHeightCompletelyInViewport) {
|
||||
this.forceCloseInlineMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fadeInTimeout) {
|
||||
this.handleFadeInInlineMenuIframe();
|
||||
}
|
||||
@@ -289,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
|
||||
this.announceAriaAlert(this.ariaAlert, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is completely within the browser viewport.
|
||||
*/
|
||||
private isElementCompletelyWithinViewport(elementPosition: DOMRect) {
|
||||
// An element that lacks size should be considered within the viewport
|
||||
if (!elementPosition.height || !elementPosition.width) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [viewportHeight, viewportWidth] = this.getViewportSize();
|
||||
|
||||
const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth;
|
||||
const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0;
|
||||
const topSideIsWithinViewport = (elementPosition.top || 0) >= 0;
|
||||
const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight;
|
||||
|
||||
return (
|
||||
rightSideIsWithinViewport &&
|
||||
leftSideIsWithinViewport &&
|
||||
topSideIsWithinViewport &&
|
||||
bottomSideIsWithinViewport
|
||||
);
|
||||
}
|
||||
|
||||
/** Use Visual Viewport API if available (better for mobile/zoom) */
|
||||
private getViewportSize(): [
|
||||
VisualViewport["height"] | Window["innerHeight"],
|
||||
VisualViewport["width"] | Window["innerWidth"],
|
||||
] {
|
||||
if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) {
|
||||
return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width];
|
||||
}
|
||||
|
||||
return [globalThis.window.innerHeight, globalThis.window.innerWidth];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the page color scheme meta tag and sends a message to the iframe
|
||||
* to update its color scheme. Will default to "normal" if the meta tag
|
||||
|
||||
@@ -1400,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, {
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 1.0,
|
||||
threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "ppremiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule, CurrencyPipe, Location } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
SectionComponent,
|
||||
],
|
||||
})
|
||||
export class PremiumV2Component extends BasePremiumComponent {
|
||||
export class PremiumV2Component extends BasePremiumComponent implements OnInit {
|
||||
priceString: string;
|
||||
|
||||
constructor(
|
||||
@@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
billingApiService: BillingApiServiceAbstraction,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
@@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent {
|
||||
billingAccountProfileStateService,
|
||||
toastService,
|
||||
accountService,
|
||||
billingApiService,
|
||||
);
|
||||
|
||||
}
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
// Support old price string. Can be removed in future once all translations are properly updated.
|
||||
const thePrice = this.currencyPipe.transform(this.price, "$");
|
||||
// Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix.
|
||||
const formattedPrice = this.platformUtilsService.isSafari()
|
||||
? thePrice.replace("$", "$$$")
|
||||
: thePrice;
|
||||
this.priceString = i18nService.t("premiumPriceV2", formattedPrice);
|
||||
this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice);
|
||||
if (this.priceString.indexOf("%price%") > -1) {
|
||||
this.priceString = this.priceString.replace("%price%", thePrice);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs";
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./popup-layout.stories";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { inject, Inject, Injectable } from "@angular/core";
|
||||
import { inject, Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
|
||||
@@ -381,4 +381,88 @@ describe("AddEditV2Component", () => {
|
||||
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadAddEditCipherData", () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
addEditCipherInfo$.next({
|
||||
cipher: {
|
||||
name: "InitialName",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
password: "initialPassword",
|
||||
username: "initialUsername",
|
||||
uris: [{ uri: "https://initial.com" }],
|
||||
},
|
||||
},
|
||||
} as AddEditCipherInfo);
|
||||
queryParams$.next({});
|
||||
tick();
|
||||
|
||||
cipherServiceMock.setAddEditCipherInfo.mockClear();
|
||||
}));
|
||||
|
||||
it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
|
||||
const newCipherInfo = {
|
||||
cipher: {
|
||||
name: "UpdatedName",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
password: "updatedPassword",
|
||||
uris: [{ uri: "https://updated.com" }],
|
||||
},
|
||||
},
|
||||
} as AddEditCipherInfo;
|
||||
|
||||
addEditCipherInfo$.next(newCipherInfo);
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toEqual({
|
||||
name: "UpdatedName",
|
||||
password: "updatedPassword",
|
||||
loginUri: "https://updated.com",
|
||||
} as OptionalInitialValues);
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
|
||||
}));
|
||||
|
||||
it("does not reload data if config is not set", fakeAsync(() => {
|
||||
component.config = null;
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
|
||||
addEditCipherInfo$.next(null);
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toEqual({
|
||||
name: "InitialName",
|
||||
password: "initialPassword",
|
||||
username: "initialUsername",
|
||||
loginUri: "https://initial.com",
|
||||
} as OptionalInitialValues);
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("ignores messages with different commands", fakeAsync(() => {
|
||||
const initialValues = component.config.initialValues;
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "someOtherCommand" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toBe(initialValues);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 } from "@angular/core";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
@@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
||||
IconButtonModule,
|
||||
],
|
||||
})
|
||||
export class AddEditV2Component implements OnInit {
|
||||
export class AddEditV2Component implements OnInit, OnDestroy {
|
||||
headerText: string;
|
||||
config: CipherFormConfig;
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
@@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
|
||||
private messageListener: (message: any) => void;
|
||||
|
||||
async ngOnInit() {
|
||||
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
this.popupCloseWarningService.enable();
|
||||
}
|
||||
|
||||
// Listen for messages to reload cipher data when the pop up is already open
|
||||
this.messageListener = async (message: any) => {
|
||||
if (message?.command === "reloadAddEditCipherData") {
|
||||
try {
|
||||
await this.reloadCipherData();
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to reload cipher data", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.messageListener) {
|
||||
BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the cipher data when the popup is already open and new form data is submitted.
|
||||
* This completely replaces the initialValues to clear any stale data from the previous submission.
|
||||
*/
|
||||
private async reloadCipherData() {
|
||||
if (!this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
const latestCipherInfo = await firstValueFrom(
|
||||
this.cipherService.addEditCipherInfo$(activeUserId),
|
||||
);
|
||||
|
||||
if (latestCipherInfo != null) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo),
|
||||
};
|
||||
|
||||
// Be sure to clear the "cached" cipher info, so it doesn't get used again
|
||||
await this.cipherService.setAddEditCipherInfo(null, activeUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
|
||||
import { CipherListView, CopyableCipherFields } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
|
||||
|
||||
import { ItemCopyActionsComponent } from "./item-copy-actions.component";
|
||||
|
||||
describe("ItemCopyActionsComponent", () => {
|
||||
let fixture: ComponentFixture<ItemCopyActionsComponent>;
|
||||
let component: ItemCopyActionsComponent;
|
||||
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = {
|
||||
t: jest.fn((key: string) => `translated-${key}`),
|
||||
} as unknown as jest.Mocked<I18nService>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ItemModule,
|
||||
IconButtonModule,
|
||||
MenuModule,
|
||||
ItemCopyActionsComponent, // standalone
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{
|
||||
provide: VaultPopupCopyButtonsService,
|
||||
useValue: {
|
||||
showQuickCopyActions$: of(true),
|
||||
} satisfies Partial<VaultPopupCopyButtonsService>,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ItemCopyActionsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Default cipher so tests can override as needed
|
||||
component.cipher = {
|
||||
name: "My cipher",
|
||||
viewPassword: true,
|
||||
login: { username: null, password: null, totp: null },
|
||||
card: { code: null, number: null },
|
||||
identity: {
|
||||
fullAddressForCopy: null,
|
||||
email: null,
|
||||
username: null,
|
||||
phone: null,
|
||||
},
|
||||
sshKey: {
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
notes: null,
|
||||
copyableFields: [],
|
||||
} as unknown as CipherViewLike;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("findSingleCopyableItem", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the single item with value and translates its key", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: false,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-copyUsername",
|
||||
field: "username",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
|
||||
});
|
||||
|
||||
it("returns null when no items have a value", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: false,
|
||||
password: false,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when more than one item has a value", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableLogin", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns username with special-case logic when password is hidden and both username/password exist and no totp", () => {
|
||||
(component.cipher as CipherView).viewPassword = false;
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
totp: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableLogin;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-copyUsername",
|
||||
field: "username",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
|
||||
});
|
||||
|
||||
it("returns null when password is hidden but multiple fields exist, ensuring username and totp are shown in the menu UI ", () => {
|
||||
(component.cipher as CipherView).viewPassword = false;
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
totp: true,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableLogin;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to findSingleCopyableItem when password is visible", () => {
|
||||
const findSingleCopyableItemSpy = jest.spyOn(component, "findSingleCopyableItem");
|
||||
(component.cipher as CipherView).viewPassword = true;
|
||||
|
||||
void component.singleCopyableLogin;
|
||||
expect(findSingleCopyableItemSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableCard", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns security code when it is the only available card value", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
securityCode: true,
|
||||
cardNumber: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableCard;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-securityCode",
|
||||
field: "securityCode",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("securityCode");
|
||||
});
|
||||
|
||||
it("returns null when both card number and security code are available", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
securityCode: true,
|
||||
cardNumber: true,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableCard;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableIdentity", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the only copyable identity field", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
address: false,
|
||||
email: true,
|
||||
username: false,
|
||||
phone: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableIdentity;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-email",
|
||||
field: "email",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("email");
|
||||
});
|
||||
|
||||
it("returns null when multiple identity fields are available", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
address: true,
|
||||
email: true,
|
||||
username: false,
|
||||
phone: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableIdentity;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("has*Values in non-list view", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("computes hasLoginValues from login fields", () => {
|
||||
(component.cipher as CipherView).login = {
|
||||
username: "user",
|
||||
password: null,
|
||||
totp: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasLoginValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).login = {
|
||||
username: null,
|
||||
password: null,
|
||||
totp: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasLoginValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasCardValues from card fields", () => {
|
||||
(component.cipher as CipherView).card = { code: "123", number: null } as any;
|
||||
|
||||
expect(component.hasCardValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).card = { code: null, number: null } as any;
|
||||
|
||||
expect(component.hasCardValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasIdentityValues from identity fields", () => {
|
||||
(component.cipher as CipherView).identity = {
|
||||
fullAddressForCopy: null,
|
||||
email: "test@example.com",
|
||||
username: null,
|
||||
phone: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasIdentityValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).identity = {
|
||||
fullAddressForCopy: null,
|
||||
email: null,
|
||||
username: null,
|
||||
phone: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasIdentityValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasSecureNoteValue from notes", () => {
|
||||
(component.cipher as CipherView).notes = "Some note" as any;
|
||||
expect(component.hasSecureNoteValue).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).notes = null as any;
|
||||
expect(component.hasSecureNoteValue).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasSshKeyValues from sshKey fields", () => {
|
||||
(component.cipher as CipherView).sshKey = {
|
||||
privateKey: "priv",
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasSshKeyValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).sshKey = {
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasSshKeyValues).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("has*Values in list view", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("uses copyableFields for login values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
"CardNumber",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasLoginValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"CardNumber",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasLoginValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for card values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"CardSecurityCode",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasCardValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasCardValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for identity values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"IdentityEmail",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasIdentityValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasIdentityValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for secure note value", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"SecureNotes",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSecureNoteValue).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSecureNoteValue).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for ssh key values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = ["SshKey"] as CopyableCipherFields[];
|
||||
expect(component.hasSshKeyValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSshKeyValues).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,17 +54,20 @@ export class ItemCopyActionsComponent {
|
||||
{ key: "copyPassword", field: "password" },
|
||||
{ key: "copyVerificationCode", field: "totp" },
|
||||
];
|
||||
// If both the password and username are visible but the password is hidden, return the username
|
||||
// If both the password and username are visible but the password is hidden and there's no
|
||||
// totp code to copy return the username
|
||||
if (
|
||||
!this.cipher.viewPassword &&
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") &&
|
||||
!CipherViewLikeUtils.hasCopyableValue(this.cipher, "totp")
|
||||
) {
|
||||
return {
|
||||
key: this.i18nService.t("copyUsername"),
|
||||
field: "username" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return this.findSingleCopyableItem(loginItems);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
|
||||
import {
|
||||
@@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => {
|
||||
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
|
||||
.mockImplementation();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
|
||||
jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue();
|
||||
global.chrome = {
|
||||
...global.chrome,
|
||||
runtime: {
|
||||
...global.chrome?.runtime,
|
||||
sendMessage: jest.fn().mockResolvedValue(undefined),
|
||||
getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a message to refresh data when the popup is already open", async () => {
|
||||
const existingPopupTab = {
|
||||
id: 123,
|
||||
windowId: 456,
|
||||
url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`,
|
||||
} as chrome.tabs.Tab;
|
||||
|
||||
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]);
|
||||
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
|
||||
const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties");
|
||||
|
||||
await openAddEditVaultItemPopout(
|
||||
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
|
||||
{
|
||||
cipherType: CipherType.Login,
|
||||
},
|
||||
);
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith({
|
||||
command: "reloadAddEditCipherData",
|
||||
data: { cipherId: undefined, cipherType: CipherType.Login },
|
||||
});
|
||||
expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeAddEditVaultItemPopout", () => {
|
||||
|
||||
@@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout(
|
||||
addEditCipherUrl += formatQueryString("uri", url);
|
||||
}
|
||||
|
||||
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
|
||||
singleActionKey,
|
||||
senderWindowId: windowId,
|
||||
});
|
||||
const extensionUrl = chrome.runtime.getURL("popup/index.html");
|
||||
const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
|
||||
const existingPopup = existingPopupTabs.find((tab) =>
|
||||
tab.url?.includes(`singleActionPopout=${singleActionKey}`),
|
||||
);
|
||||
// Check if the an existing popup is already open
|
||||
try {
|
||||
await chrome.runtime.sendMessage({
|
||||
command: "reloadAddEditCipherData",
|
||||
data: { cipherId, cipherType },
|
||||
});
|
||||
await BrowserApi.updateWindowProperties(existingPopup.windowId, {
|
||||
focused: true,
|
||||
});
|
||||
} catch {
|
||||
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
|
||||
singleActionKey,
|
||||
senderWindowId: windowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,11 @@ export class Program extends BaseProgram {
|
||||
})
|
||||
.option("--check", "Check lock status.", async () => {
|
||||
await this.exitIfNotAuthed();
|
||||
const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$))
|
||||
?.id;
|
||||
await this.serviceContainer.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(
|
||||
userId,
|
||||
);
|
||||
|
||||
const authStatus = await this.serviceContainer.authService.getAuthStatus();
|
||||
if (authStatus === AuthenticationStatus.Unlocked) {
|
||||
|
||||
8
apps/desktop/desktop_native/Cargo.lock
generated
8
apps/desktop/desktop_native/Cargo.lock
generated
@@ -1863,9 +1863,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mockall"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
|
||||
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"downcast",
|
||||
@@ -1877,9 +1877,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mockall_derive"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
|
||||
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -9,7 +9,7 @@ publish.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
mockall = "=0.13.1"
|
||||
mockall = "=0.14.0"
|
||||
serial_test = "=3.2.0"
|
||||
tracing.workspace = true
|
||||
windows = { workspace = true, features = [
|
||||
|
||||
@@ -272,6 +272,7 @@ mod tests {
|
||||
#[serial]
|
||||
fn send_input_succeeds() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 1);
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -279,6 +280,8 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,9 +291,11 @@ mod tests {
|
||||
)]
|
||||
fn send_input_fails_sent_zero() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 0);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -298,6 +303,9 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,9 +313,11 @@ mod tests {
|
||||
#[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")]
|
||||
fn send_input_fails_sent_mismatch() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 2);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -315,5 +325,8 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ mod tests {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse.checkpoint();
|
||||
ctxse
|
||||
.expect()
|
||||
.once()
|
||||
@@ -198,6 +199,7 @@ mod tests {
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
let len = get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(
|
||||
@@ -206,6 +208,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(len, 0);
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -215,6 +220,7 @@ mod tests {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse.checkpoint();
|
||||
ctxse.expect().with(predicate::eq(0)).returning(|_| {});
|
||||
|
||||
mock_handle
|
||||
@@ -223,13 +229,18 @@ mod tests {
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_succeeds() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
@@ -246,11 +257,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(title.len(), 43); // That extra slot in the buffer for null char
|
||||
|
||||
assert_eq!(title, "*******************************************");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_returns_empty_string() {
|
||||
let mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
@@ -273,10 +284,13 @@ mod tests {
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -290,9 +304,12 @@ mod tests {
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import {
|
||||
DevicesIcon,
|
||||
RegistrationUserAddIcon,
|
||||
@@ -39,15 +40,19 @@ import {
|
||||
TwoFactorAuthGuard,
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
|
||||
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
|
||||
|
||||
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
|
||||
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
|
||||
import { SendComponent } from "./tools/send/send.component";
|
||||
import { SendV2Component } from "./tools/send-v2/send-v2.component";
|
||||
|
||||
/**
|
||||
* Data properties acceptable for use in route objects in the desktop
|
||||
@@ -99,7 +104,10 @@ const routes: Routes = [
|
||||
{
|
||||
path: "vault",
|
||||
component: VaultV2Component,
|
||||
canActivate: [authGuard],
|
||||
canActivate: [
|
||||
authGuard,
|
||||
canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false),
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "send",
|
||||
@@ -325,6 +333,21 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: DesktopLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: "new-vault",
|
||||
component: VaultComponent,
|
||||
},
|
||||
{
|
||||
path: "new-sends",
|
||||
component: SendV2Component,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<bit-layout>
|
||||
<app-side-nav slot="side-nav">
|
||||
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
|
||||
|
||||
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
|
||||
</app-side-nav>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</bit-layout>
|
||||
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopLayoutComponent } from "./desktop-layout.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopLayoutComponent", () => {
|
||||
let component: DesktopLayoutComponent;
|
||||
let fixture: ComponentFixture<DesktopLayoutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-layout component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const layoutElement = compiled.querySelector("bit-layout");
|
||||
|
||||
expect(layoutElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports content projection for side-nav", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const ngContent = compiled.querySelectorAll("ng-content");
|
||||
|
||||
expect(ngContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
19
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
19
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
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",
|
||||
})
|
||||
export class DesktopLayoutComponent {
|
||||
protected readonly logo = PasswordManagerLogo;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<bit-side-nav [variant]="variant()">
|
||||
<ng-content></ng-content>
|
||||
</bit-side-nav>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopSideNavComponent", () => {
|
||||
let component: DesktopSideNavComponent;
|
||||
let fixture: ComponentFixture<DesktopSideNavComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopSideNavComponent, NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopSideNavComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-side-nav component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses primary variant by default", () => {
|
||||
expect(component.variant()).toBe("primary");
|
||||
});
|
||||
|
||||
it("accepts variant input", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.variant()).toBe("secondary");
|
||||
});
|
||||
|
||||
it.skip("passes variant to bit-side-nav", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary");
|
||||
});
|
||||
});
|
||||
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { NavigationModule, SideNavVariant } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "app-side-nav",
|
||||
templateUrl: "desktop-side-nav.component.html",
|
||||
imports: [CommonModule, NavigationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DesktopSideNavComponent {
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
|
||||
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div id="sends" class="vault">
|
||||
<div id="items" class="items">
|
||||
<div class="content">
|
||||
<div class="list full-height" *ngIf="filteredSends && filteredSends.length">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let s of filteredSends"
|
||||
appStopClick
|
||||
(click)="selectSend(s.id)"
|
||||
title="{{ 'viewItem' | i18n }}"
|
||||
(contextmenu)="viewSendMenu(s)"
|
||||
[ngClass]="{ active: s.id === sendId }"
|
||||
[attr.aria-pressed]="s.id === sendId"
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-badges">
|
||||
<ng-container *ngIf="s.disabled">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'disabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.password">
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.expired">
|
||||
<i
|
||||
class="bwi bwi-clock"
|
||||
appStopProp
|
||||
title="{{ 'expired' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "expired" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.pendingDelete">
|
||||
<i
|
||||
class="bwi bwi-trash"
|
||||
appStopProp
|
||||
title="{{ 'pendingDeletion' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-details">{{ s.deletionDate | date }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="no-items" *ngIf="!filteredSends || !filteredSends.length">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" *ngIf="!loaded" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="loaded">
|
||||
<img class="no-items-image" aria-hidden="true" />
|
||||
<p>{{ "noItemsInList" | i18n }}</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSend()"
|
||||
class="block primary"
|
||||
appA11yTitle="{{ 'addItem' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
*ngIf="action == 'add' || action == 'edit'"
|
||||
[sendId]="sendId"
|
||||
[type]="selectedSendType"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="cancel($event)"
|
||||
(onDeletedSend)="deletedSend($event)"
|
||||
></app-send-add-edit>
|
||||
<div class="logo" *ngIf="!action">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
364
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
364
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
@@ -0,0 +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;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
233
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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: [CommonModule, JslibModule, FormsModule, AddEditComponent],
|
||||
templateUrl: "./send-v2.component.html",
|
||||
})
|
||||
export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;
|
||||
|
||||
// The ID of the currently selected Send item being viewed or edited
|
||||
sendId: string;
|
||||
|
||||
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
|
||||
action: Action = Action.None;
|
||||
|
||||
constructor(
|
||||
sendService: SendService,
|
||||
i18nService: I18nService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
environmentService: EnvironmentService,
|
||||
private broadcasterService: BroadcasterService,
|
||||
ngZone: NgZone,
|
||||
searchService: SearchService,
|
||||
policyService: PolicyService,
|
||||
private searchBarService: SearchBarService,
|
||||
logService: LogService,
|
||||
sendApiService: SendApiService,
|
||||
dialogService: DialogService,
|
||||
toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {
|
||||
super(
|
||||
sendService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
environmentService,
|
||||
ngZone,
|
||||
searchService,
|
||||
policyService,
|
||||
logService,
|
||||
sendApiService,
|
||||
dialogService,
|
||||
toastService,
|
||||
accountService,
|
||||
);
|
||||
|
||||
// Listen to search bar changes and update the Send list filter
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.searchBarService.searchText$.subscribe((searchText) => {
|
||||
this.searchText = searchText;
|
||||
this.searchTextChanged();
|
||||
setTimeout(() => this.cdr.detectChanges(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the component: enable search bar, subscribe to sync events, and load Send items
|
||||
async ngOnInit() {
|
||||
this.searchBarService.setEnabled(true);
|
||||
this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends"));
|
||||
|
||||
await super.ngOnInit();
|
||||
|
||||
// Listen for sync completion events to refresh the Send list
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.ngZone.run(async () => {
|
||||
switch (message.command) {
|
||||
case "syncCompleted":
|
||||
await this.load();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.load();
|
||||
}
|
||||
|
||||
// Clean up subscriptions and disable search bar when component is destroyed
|
||||
ngOnDestroy() {
|
||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||
this.searchBarService.setEnabled(false);
|
||||
}
|
||||
|
||||
// Load Send items from the service and display them in the list.
|
||||
// Subscribes to sendViews$ observable to get updates when Sends change.
|
||||
// Manually triggers change detection to ensure UI updates immediately.
|
||||
// Note: The filter parameter is ignored in this implementation for desktop-specific behavior.
|
||||
async load(filter: (send: SendView) => boolean = null) {
|
||||
this.loading = true;
|
||||
this.sendService.sendViews$
|
||||
.pipe(
|
||||
mergeMap(async (sends) => {
|
||||
this.sends = sends;
|
||||
await this.search(null);
|
||||
// Trigger change detection after data updates
|
||||
this.cdr.detectChanges();
|
||||
}),
|
||||
)
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
.subscribe();
|
||||
if (this.onSuccessfulLoad != null) {
|
||||
await this.onSuccessfulLoad();
|
||||
} else {
|
||||
// Default action
|
||||
this.selectAll();
|
||||
}
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
// Open the add Send form to create a new Send item
|
||||
async addSend() {
|
||||
this.action = Action.Add;
|
||||
if (this.addEditComponent != null) {
|
||||
await this.addEditComponent.resetAndLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the add/edit form and return to the list view
|
||||
cancel(s: SendView) {
|
||||
this.action = Action.None;
|
||||
this.sendId = null;
|
||||
}
|
||||
|
||||
// Handle when a Send is deleted: refresh the list and close the edit form
|
||||
async deletedSend(s: SendView) {
|
||||
await this.refresh();
|
||||
this.action = Action.None;
|
||||
this.sendId = null;
|
||||
}
|
||||
|
||||
// Handle when a Send is saved: refresh the list and re-select the saved Send
|
||||
async savedSend(s: SendView) {
|
||||
await this.refresh();
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.selectSend(s.id);
|
||||
}
|
||||
|
||||
// Select a Send from the list and open it in the edit form.
|
||||
// If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads.
|
||||
async selectSend(sendId: string) {
|
||||
if (sendId === this.sendId && this.action === Action.Edit) {
|
||||
return;
|
||||
}
|
||||
this.action = Action.Edit;
|
||||
this.sendId = sendId;
|
||||
if (this.addEditComponent != null) {
|
||||
this.addEditComponent.sendId = sendId;
|
||||
await this.addEditComponent.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the type (text or file) of the currently selected Send for the edit form
|
||||
get selectedSendType() {
|
||||
return this.sends.find((s) => s.id === this.sendId)?.type;
|
||||
}
|
||||
|
||||
// Show the right-click context menu for a Send with options to copy link, remove password, or delete
|
||||
viewSendMenu(send: SendView) {
|
||||
const menu: RendererMenuItem[] = [];
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyLink"),
|
||||
click: () => this.copy(send),
|
||||
});
|
||||
if (send.password && !send.disabled) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("removePassword"),
|
||||
click: async () => {
|
||||
await this.removePassword(send);
|
||||
if (this.sendId === send.id) {
|
||||
this.sendId = null;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.selectSend(send.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
label: this.i18nService.t("delete"),
|
||||
click: async () => {
|
||||
await this.delete(send);
|
||||
await this.deletedSend(send);
|
||||
},
|
||||
});
|
||||
|
||||
invokeMenu(menu);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-li bwi-check text-success" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-li bwi-check text-success" aria-hidden="true"></i>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component } from "@angular/core";
|
||||
import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -28,6 +29,7 @@ export class PremiumComponent extends BasePremiumComponent {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
billingApiService: BillingApiServiceAbstraction,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
@@ -39,6 +41,7 @@ export class PremiumComponent extends BasePremiumComponent {
|
||||
billingAccountProfileStateService,
|
||||
toastService,
|
||||
accountService,
|
||||
billingApiService,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1490,15 @@
|
||||
"premiumSignUpStorage": {
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
},
|
||||
"premiumSignUpStorageV2": {
|
||||
"message": "$SIZE$ encrypted storage for file attachments.",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"content": "$1",
|
||||
"example": "1 GB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSignUpTwoStepOptions": {
|
||||
"message": "Proprietary two-step login options such as YubiKey and Duo."
|
||||
},
|
||||
@@ -2228,6 +2237,10 @@
|
||||
"contactInfo": {
|
||||
"message": "Contact information"
|
||||
},
|
||||
"send": {
|
||||
"message": "Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"allSends": {
|
||||
"message": "All Sends",
|
||||
"description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
@@ -2991,7 +3004,8 @@
|
||||
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
|
||||
},
|
||||
"vault": {
|
||||
"message": "Vault"
|
||||
"message": "Vault",
|
||||
"description": "'Vault' is a noun and refers to the Bitwarden Vault feature."
|
||||
},
|
||||
"loginWithMasterPassword": {
|
||||
"message": "Log in with master password"
|
||||
|
||||
70
apps/desktop/src/vault/app/vault-v3/vault.component.html
Normal file
70
apps/desktop/src/vault/app/vault-v3/vault.component.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<div id="vault" class="vault vault-v2" attr.aria-hidden="{{ showingModal }}">
|
||||
<app-vault-items-v2
|
||||
id="items"
|
||||
class="items"
|
||||
[activeCipherId]="cipherId"
|
||||
(onCipherClicked)="viewCipher($event)"
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
<div class="details" *ngIf="!!action">
|
||||
<app-vault-item-footer
|
||||
id="footer"
|
||||
#footer
|
||||
[cipher]="cipher"
|
||||
[action]="action"
|
||||
(onEdit)="editCipher($event)"
|
||||
(onRestore)="restoreCipher()"
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<div class="box">
|
||||
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
|
||||
</app-cipher-view>
|
||||
<vault-cipher-form
|
||||
#vaultForm
|
||||
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
|
||||
formId="cipherForm"
|
||||
[config]="config"
|
||||
(cipherSaved)="savedCipher($event)"
|
||||
[submitBtn]="footer?.submitBtn"
|
||||
(formStatusChange$)="formStatusChanged($event)"
|
||||
>
|
||||
<bit-item slot="attachment-button">
|
||||
<button
|
||||
bit-item-content
|
||||
type="button"
|
||||
(click)="openAttachmentsDialog()"
|
||||
[disabled]="formDisabled"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "attachments" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
</vault-cipher-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="logo"
|
||||
class="logo"
|
||||
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #folderAddEdit></ng-template>
|
||||
1020
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
1020
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
|
||||
<bit-nav-item
|
||||
icon="bwi-dashboard"
|
||||
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
|
||||
*ngIf="organization.canAccessReports"
|
||||
[text]="'accessIntelligence' | i18n"
|
||||
route="access-intelligence"
|
||||
></bit-nav-item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { action } from "storybook/actions";
|
||||
|
||||
import { AccessItemType, AccessItemView } from "./access-selector.models";
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
@@ -82,7 +82,10 @@
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||
| i18n
|
||||
: `${(providedStorageGb$ | async)} GB`
|
||||
: (storagePrice$ | async | currency: "$")
|
||||
: ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,8 @@ import { debounceTime } from "rxjs/operators";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent {
|
||||
return {
|
||||
seat: premiumPlan.passwordManager.annualPrice,
|
||||
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
|
||||
providedStorageGb: premiumPlan.passwordManager.providedStorageGB,
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
@@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent {
|
||||
|
||||
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
|
||||
|
||||
providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb));
|
||||
|
||||
protected isLoadingPrices$ = this.premiumPrices$.pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
@@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent {
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
private subscriptionPricingService: DefaultSubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
) {
|
||||
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Router } from "@angular/router";
|
||||
@@ -28,12 +28,11 @@ import {
|
||||
UnifiedUpgradeDialogStep,
|
||||
} from "./unified-upgrade-dialog.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-upgrade-account",
|
||||
template: "",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockUpgradeAccountComponent {
|
||||
readonly dialogTitleMessageOverride = input<string | null>(null);
|
||||
@@ -42,12 +41,11 @@ class MockUpgradeAccountComponent {
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
}
|
||||
|
||||
// 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-upgrade-payment",
|
||||
template: "",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockUpgradePaymentComponent {
|
||||
readonly selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
|
||||
@@ -77,10 +75,56 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
planSelectionStepTitleOverride: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create and configure a fresh component instance with custom dialog data
|
||||
*/
|
||||
async function createComponentWithDialogData(
|
||||
dialogData: UnifiedUpgradeDialogParams,
|
||||
waitForStable = false,
|
||||
): Promise<{
|
||||
fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
|
||||
component: UnifiedUpgradeDialogComponent;
|
||||
}> {
|
||||
TestBed.resetTestingModule();
|
||||
jest.clearAllMocks();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: dialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const newFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
newFixture.detectChanges();
|
||||
|
||||
if (waitForStable) {
|
||||
await newFixture.whenStable();
|
||||
}
|
||||
|
||||
return { fixture: newFixture, component: newComponent };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock: no premium interest
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
@@ -117,49 +161,63 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
});
|
||||
|
||||
it("should initialize with custom initial step", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.Payment,
|
||||
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
|
||||
expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
|
||||
});
|
||||
|
||||
describe("ngOnInit premium interest handling", () => {
|
||||
it("should check premium interest on initialization", async () => {
|
||||
// Component already initialized in beforeEach
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should set hasPremiumInterest signal and clear premium interest when it exists", async () => {
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
|
||||
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(
|
||||
defaultDialogData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(customComponent["hasPremiumInterest"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not set hasPremiumInterest signal or clear when premium interest does not exist", async () => {
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(defaultDialogData);
|
||||
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
|
||||
expect(customComponent["hasPremiumInterest"]()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom dialog title", () => {
|
||||
it("should use null as default when no override is provided", () => {
|
||||
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("should use custom title when provided in dialog config", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.PlanSelection,
|
||||
@@ -167,28 +225,7 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan");
|
||||
});
|
||||
@@ -221,8 +258,6 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
});
|
||||
|
||||
it("should be set to true when provided in dialog config", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: null,
|
||||
@@ -230,108 +265,32 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onComplete with premium interest", () => {
|
||||
it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => {
|
||||
describe("onComplete", () => {
|
||||
it("should route to /vault when upgrading to premium with premium interest", async () => {
|
||||
// Set up component with premium interest
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
|
||||
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
|
||||
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const result: UpgradePaymentResult = {
|
||||
status: "upgradedToPremium",
|
||||
organizationId: null,
|
||||
};
|
||||
|
||||
await component["onComplete"](result);
|
||||
const { component: customComponent } = await createComponentWithDialogData(
|
||||
defaultDialogData,
|
||||
true,
|
||||
);
|
||||
|
||||
// Premium interest should be set and cleared during ngOnInit
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToPremium",
|
||||
organizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not clear premium interest when upgrading to families", async () => {
|
||||
const result: UpgradePaymentResult = {
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-123",
|
||||
};
|
||||
|
||||
await component["onComplete"](result);
|
||||
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled();
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use standard redirect when no premium interest exists", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
expect(customComponent["hasPremiumInterest"]()).toBe(true);
|
||||
|
||||
const result: UpgradePaymentResult = {
|
||||
status: "upgradedToPremium",
|
||||
@@ -340,10 +299,55 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
|
||||
// Should route to /vault because hasPremiumInterest signal is true
|
||||
// No additional service calls should be made in onComplete
|
||||
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToPremium",
|
||||
organizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dialog when upgrading to families (premium interest not relevant)", async () => {
|
||||
const result: UpgradePaymentResult = {
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-123",
|
||||
};
|
||||
|
||||
await component["onComplete"](result);
|
||||
|
||||
// Premium interest logic only runs for premium upgrades, not families
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use standard redirect when upgrading to premium without premium interest", async () => {
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
// No premium interest
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
// Verify no premium interest was set during ngOnInit
|
||||
expect(customComponent["hasPremiumInterest"]()).toBe(false);
|
||||
|
||||
const result: UpgradePaymentResult = {
|
||||
status: "upgradedToPremium",
|
||||
organizationId: null,
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
// Should use standard redirect because hasPremiumInterest signal is false
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([
|
||||
"/settings/subscription/user-subscription",
|
||||
]);
|
||||
@@ -354,70 +358,44 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCloseClicked with premium interest", () => {
|
||||
it("should clear premium interest when modal is closed", async () => {
|
||||
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
|
||||
|
||||
describe("onCloseClicked", () => {
|
||||
it("should close dialog without clearing premium interest (cleared in ngOnInit)", async () => {
|
||||
await component["onCloseClicked"]();
|
||||
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
// Premium interest should have been cleared only once during ngOnInit, not again here
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousStep with premium interest", () => {
|
||||
it("should NOT clear premium interest when navigating between steps", async () => {
|
||||
describe("previousStep", () => {
|
||||
it("should go back to plan selection when on payment step", async () => {
|
||||
component["step"].set(UnifiedUpgradeDialogStep.Payment);
|
||||
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
|
||||
|
||||
await component["previousStep"]();
|
||||
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
|
||||
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
expect(component["selectedPlan"]()).toBeNull();
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should clear premium interest when backing out of dialog completely", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
it("should close dialog when backing out from plan selection step (no premium interest cleared)", async () => {
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.Payment,
|
||||
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
|
||||
};
|
||||
|
||||
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
// Start at payment step, go back once to reach plan selection, then go back again to close
|
||||
await customComponent["previousStep"]();
|
||||
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
|
||||
mockAccount.id,
|
||||
);
|
||||
// Premium interest cleared only in ngOnInit, not in previousStep
|
||||
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject, OnInit, signal } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, Inject, OnInit, signal } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
@@ -63,10 +63,9 @@ export type UnifiedUpgradeDialogParams = {
|
||||
redirectOnCompletion?: boolean;
|
||||
};
|
||||
|
||||
// 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-unified-upgrade-dialog",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
@@ -87,6 +86,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
protected readonly account = signal<Account | null>(null);
|
||||
protected readonly planSelectionStepTitleOverride = signal<string | null>(null);
|
||||
protected readonly hideContinueWithoutUpgradingButton = signal<boolean>(false);
|
||||
protected readonly hasPremiumInterest = signal(false);
|
||||
|
||||
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
|
||||
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
|
||||
@@ -98,7 +98,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
private premiumInterestStateService: PremiumInterestStateService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.account.set(this.params.account);
|
||||
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
|
||||
this.selectedPlan.set(this.params.selectedPlan ?? null);
|
||||
@@ -106,6 +106,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
this.hideContinueWithoutUpgradingButton.set(
|
||||
this.params.hideContinueWithoutUpgradingButton ?? false,
|
||||
);
|
||||
|
||||
/*
|
||||
* Check if the user has premium interest at the point we open the dialog.
|
||||
* If they do, record it on a component-level signal and clear the user's premium interest.
|
||||
* This prevents us from having to clear it at every dialog conclusion point.
|
||||
* */
|
||||
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
|
||||
this.params.account.id,
|
||||
);
|
||||
if (hasPremiumInterest) {
|
||||
this.hasPremiumInterest.set(true);
|
||||
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
|
||||
}
|
||||
}
|
||||
|
||||
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {
|
||||
@@ -113,8 +126,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
this.nextStep();
|
||||
}
|
||||
protected async onCloseClicked(): Promise<void> {
|
||||
// Clear premium interest when user closes/abandons modal
|
||||
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
|
||||
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
|
||||
}
|
||||
|
||||
@@ -135,8 +146,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
this.step.set(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
this.selectedPlan.set(null);
|
||||
} else {
|
||||
// Clear premium interest when backing out of dialog completely
|
||||
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
|
||||
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
|
||||
}
|
||||
}
|
||||
@@ -161,11 +170,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
|
||||
// Check premium interest and route to vault for marketing-initiated premium upgrades
|
||||
if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
|
||||
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
|
||||
this.params.account.id,
|
||||
);
|
||||
if (hasPremiumInterest) {
|
||||
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
|
||||
if (this.hasPremiumInterest()) {
|
||||
await this.router.navigate(["/vault"]);
|
||||
return; // Exit early, don't use redirectOnCompletion
|
||||
}
|
||||
|
||||
@@ -620,7 +620,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get storageGb() {
|
||||
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
|
||||
return Math.max(
|
||||
0,
|
||||
(this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
}
|
||||
|
||||
passwordManagerSeatTotal(plan: PlanResponse): number {
|
||||
@@ -644,12 +647,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
plan.PasswordManager.additionalStoragePricePerGb *
|
||||
// TODO: Eslint upgrade. Please resolve this since the null check does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
|
||||
);
|
||||
return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb;
|
||||
}
|
||||
|
||||
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
|
||||
{{
|
||||
"gbEncryptedFileStorage"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + " GB"
|
||||
}}
|
||||
</li>
|
||||
<li *ngIf="selectableProduct.hasGroups">
|
||||
@@ -239,7 +239,7 @@
|
||||
<bit-hint class="tw-text-sm">{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n
|
||||
: "1 GB"
|
||||
: `${selectedPlan.PasswordManager.baseStorageGb} GB`
|
||||
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
|
||||
: ("month" | i18n)
|
||||
}}</bit-hint>
|
||||
|
||||
@@ -654,6 +654,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
if (this.singleOrgPolicyBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate billing form for paid plans during creation
|
||||
if (this.createOrganization && this.selectedPlan.type !== PlanType.Free) {
|
||||
this.billingFormGroup.markAllAsTouched();
|
||||
if (this.billingFormGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const doSubmit = async (): Promise<string> => {
|
||||
let orgId: string;
|
||||
if (this.createOrganization) {
|
||||
@@ -703,11 +711,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
return orgId;
|
||||
};
|
||||
|
||||
this.formPromise = doSubmit();
|
||||
const organizationId = await this.formPromise;
|
||||
this.onSuccess.emit({ organizationId: organizationId });
|
||||
// TODO: No one actually listening to this message?
|
||||
this.messagingService.send("organizationCreated", { organizationId });
|
||||
try {
|
||||
this.formPromise = doSubmit();
|
||||
const organizationId = await this.formPromise;
|
||||
this.onSuccess.emit({ organizationId: organizationId });
|
||||
// TODO: No one actually listening to this message?
|
||||
this.messagingService.send("organizationCreated", { organizationId });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === "Payment method validation failed") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
protected get showTaxIdField(): boolean {
|
||||
@@ -826,6 +841,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
if (!paymentMethod) {
|
||||
throw new Error("Payment method validation failed");
|
||||
}
|
||||
await this.subscriberBillingClient.updatePaymentMethod(
|
||||
{ type: "organization", data: this.organization },
|
||||
paymentMethod,
|
||||
@@ -877,6 +895,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
if (!paymentMethod) {
|
||||
throw new Error("Payment method validation failed");
|
||||
}
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(
|
||||
this.billingFormGroup.controls.billingAddress,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import {
|
||||
BillingCustomerDiscount,
|
||||
OrganizationSubscriptionResponse,
|
||||
} from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import {
|
||||
PasswordManagerPlanFeaturesResponse,
|
||||
PlanResponse,
|
||||
SecretsManagerPlanFeaturesResponse,
|
||||
} from "@bitwarden/common/billing/models/response/plan.response";
|
||||
|
||||
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
|
||||
|
||||
import { PricingSummaryService } from "./pricing-summary.service";
|
||||
|
||||
describe("PricingSummaryService", () => {
|
||||
let service: PricingSummaryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PricingSummaryService();
|
||||
});
|
||||
|
||||
describe("getPricingSummaryData", () => {
|
||||
let mockPlan: PlanResponse;
|
||||
let mockSub: OrganizationSubscriptionResponse;
|
||||
let mockOrganization: Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock plan with password manager features
|
||||
mockPlan = {
|
||||
productTier: ProductTierType.Teams,
|
||||
PasswordManager: {
|
||||
basePrice: 0,
|
||||
seatPrice: 48,
|
||||
baseSeats: 0,
|
||||
hasAdditionalSeatsOption: true,
|
||||
hasPremiumAccessOption: false,
|
||||
premiumAccessOptionPrice: 0,
|
||||
hasAdditionalStorageOption: true,
|
||||
additionalStoragePricePerGb: 6,
|
||||
baseStorageGb: 1,
|
||||
} as PasswordManagerPlanFeaturesResponse,
|
||||
SecretsManager: {
|
||||
basePrice: 0,
|
||||
seatPrice: 72,
|
||||
baseSeats: 3,
|
||||
hasAdditionalSeatsOption: true,
|
||||
hasAdditionalServiceAccountOption: true,
|
||||
additionalPricePerServiceAccount: 6,
|
||||
baseServiceAccount: 50,
|
||||
} as SecretsManagerPlanFeaturesResponse,
|
||||
} as PlanResponse;
|
||||
|
||||
// Create mock subscription
|
||||
mockSub = {
|
||||
seats: 5,
|
||||
smSeats: 5,
|
||||
smServiceAccounts: 5,
|
||||
maxStorageGb: 2,
|
||||
customerDiscount: null,
|
||||
} as OrganizationSubscriptionResponse;
|
||||
|
||||
// Create mock organization
|
||||
mockOrganization = {
|
||||
useSecretsManager: false,
|
||||
} as Organization;
|
||||
});
|
||||
|
||||
it("should calculate pricing data correctly for password manager only", async () => {
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50, // estimatedTax
|
||||
);
|
||||
|
||||
expect(result).toEqual<PricingSummaryData>({
|
||||
selectedPlanInterval: "month",
|
||||
passwordManagerSeats: 5,
|
||||
passwordManagerSeatTotal: 240, // 48 * 5
|
||||
secretsManagerSeatTotal: 360, // 72 * 5
|
||||
additionalStorageTotal: 6, // 6 * (2 - 1)
|
||||
additionalStoragePriceMonthly: 6,
|
||||
additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used)
|
||||
totalAppliedDiscount: 0,
|
||||
secretsManagerSubtotal: 360, // 0 + 360 + 0
|
||||
passwordManagerSubtotal: 246, // 0 + 240 + 6
|
||||
total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager
|
||||
organization: mockOrganization,
|
||||
sub: mockSub,
|
||||
selectedPlan: mockPlan,
|
||||
selectedInterval: PlanInterval.Monthly,
|
||||
discountPercentageFromSub: 0,
|
||||
discountPercentage: 20,
|
||||
acceptingSponsorship: false,
|
||||
additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0
|
||||
storageGb: 1,
|
||||
isSecretsManagerTrial: false,
|
||||
estimatedTax: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate pricing data correctly with secrets manager enabled", async () => {
|
||||
mockOrganization.useSecretsManager = true;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50)
|
||||
});
|
||||
|
||||
it("should handle secrets manager trial", async () => {
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
true, // isSecretsManagerTrial
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial
|
||||
expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial
|
||||
});
|
||||
|
||||
it("should handle premium access option", async () => {
|
||||
mockPlan.PasswordManager.hasPremiumAccessOption = true;
|
||||
mockPlan.PasswordManager.premiumAccessOptionPrice = 25;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25
|
||||
});
|
||||
|
||||
it("should handle customer discount", async () => {
|
||||
mockSub.customerDiscount = {
|
||||
id: "discount1",
|
||||
active: true,
|
||||
percentOff: 10,
|
||||
appliesTo: ["subscription"],
|
||||
} as BillingCustomerDiscount;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.discountPercentageFromSub).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle zero storage calculation", async () => {
|
||||
mockSub.maxStorageGb = 1; // Same as base storage
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.additionalStorageTotal).toBe(0);
|
||||
expect(result.storageGb).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdditionalServiceAccount", () => {
|
||||
let mockPlan: PlanResponse;
|
||||
let mockSub: OrganizationSubscriptionResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan = {
|
||||
SecretsManager: {
|
||||
baseServiceAccount: 50,
|
||||
} as SecretsManagerPlanFeaturesResponse,
|
||||
} as PlanResponse;
|
||||
|
||||
mockSub = {
|
||||
smServiceAccounts: 55,
|
||||
} as OrganizationSubscriptionResponse;
|
||||
});
|
||||
|
||||
it("should return additional service accounts when used exceeds base", () => {
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(5); // Math.abs(50 - 55) = 5
|
||||
});
|
||||
|
||||
it("should return 0 when used is less than or equal to base", () => {
|
||||
mockSub.smServiceAccounts = 40;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when used equals base", () => {
|
||||
mockSub.smServiceAccounts = 50;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when plan is null", () => {
|
||||
const result = service.getAdditionalServiceAccount(null, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when plan has no SecretsManager", () => {
|
||||
mockPlan.SecretsManager = null;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,10 @@ export class PricingSummaryService {
|
||||
|
||||
const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub);
|
||||
|
||||
const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb);
|
||||
|
||||
const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
|
||||
? plan.PasswordManager.additionalStoragePricePerGb *
|
||||
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
|
||||
? plan.PasswordManager.additionalStoragePricePerGb * storageGb
|
||||
: 0;
|
||||
|
||||
const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
|
||||
@@ -66,7 +67,6 @@ export class PricingSummaryService {
|
||||
: (sub?.customerDiscount?.percentOff ?? 0);
|
||||
const discountPercentage = 20;
|
||||
const acceptingSponsorship = false;
|
||||
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
|
||||
|
||||
const total = organization?.useSecretsManager
|
||||
? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { CommonModule, DOCUMENT } from "@angular/common";
|
||||
import { Component, Inject, OnDestroy, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
DOCUMENT,
|
||||
ChangeDetectionStrategy,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { map, Observable, of, tap } from "rxjs";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule, DOCUMENT } from "@angular/common";
|
||||
import { Component, ViewChildren, QueryList, ElementRef, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ViewChildren, QueryList, ElementRef, inject, DOCUMENT } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { debounceTime, fromEvent } from "rxjs";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DOCUMENT, NgIf } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { NgIf } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnDestroy, OnInit, DOCUMENT } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, pairwise, startWith } from "rxjs";
|
||||
|
||||
@@ -3060,7 +3060,16 @@
|
||||
"message": "Upgrade your account to a Premium membership and unlock some great additional features."
|
||||
},
|
||||
"premiumSignUpStorage": {
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
},
|
||||
"premiumSignUpStorageV2": {
|
||||
"message": "$SIZE$ encrypted storage for file attachments.",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"content": "$1",
|
||||
"example": "1 GB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSignUpTwoStepOptions": {
|
||||
"message": "Proprietary two-step login options such as YubiKey and Duo."
|
||||
|
||||
@@ -21,8 +21,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import {
|
||||
@@ -100,19 +98,11 @@ export class ManageClientsComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
protected providerPortalTakeover$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM21821_ProviderPortalTakeover,
|
||||
);
|
||||
|
||||
protected suspensionActive$ = combineLatest([
|
||||
this.isAdminOrServiceUser$,
|
||||
this.providerPortalTakeover$,
|
||||
this.provider$.pipe(map((provider) => provider?.enabled ?? false)),
|
||||
]).pipe(
|
||||
map(
|
||||
([isAdminOrServiceUser, portalTakeoverEnabled, providerEnabled]) =>
|
||||
isAdminOrServiceUser && portalTakeoverEnabled && !providerEnabled,
|
||||
),
|
||||
map(([isAdminOrServiceUser, providerEnabled]) => isAdminOrServiceUser && !providerEnabled),
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -127,7 +117,6 @@ export class ManageClientsComponent implements OnInit, OnDestroy {
|
||||
private validationService: ValidationService,
|
||||
private webProviderService: WebProviderService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
route="clients"
|
||||
>
|
||||
<i
|
||||
*ngIf="!provider.enabled && (providerPortalTakeover$ | async)"
|
||||
*ngIf="!provider.enabled"
|
||||
slot="end"
|
||||
class="bwi bwi-exclamation-triangle tw-text-danger"
|
||||
title="{{ 'providerIsDisabled' | i18n }}"
|
||||
|
||||
@@ -13,8 +13,6 @@ import { ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components";
|
||||
@@ -48,7 +46,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected clientsTranslationKey$: Observable<string>;
|
||||
protected providerPortalTakeover$: Observable<boolean>;
|
||||
|
||||
protected subscriber$: Observable<NonIndividualSubscriber>;
|
||||
protected getTaxIdWarning$: () => Observable<TaxIdWarningType>;
|
||||
@@ -56,7 +53,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
private configService: ConfigService,
|
||||
private providerWarningsService: ProviderWarningsService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
@@ -101,10 +97,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.providerPortalTakeover$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM21821_ProviderPortalTakeover,
|
||||
);
|
||||
|
||||
this.subscriber$ = this.provider$.pipe(
|
||||
map((provider) => ({
|
||||
type: "provider",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { ProviderId } from "@bitwarden/common/types/guid";
|
||||
@@ -21,7 +20,6 @@ describe("ProviderWarningsService", () => {
|
||||
let service: ProviderWarningsService;
|
||||
let activatedRoute: MockProxy<ActivatedRoute>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let router: MockProxy<Router>;
|
||||
@@ -42,7 +40,6 @@ describe("ProviderWarningsService", () => {
|
||||
beforeEach(() => {
|
||||
activatedRoute = mock<ActivatedRoute>();
|
||||
apiService = mock<ApiService>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
i18nService = mock<I18nService>();
|
||||
router = mock<Router>();
|
||||
@@ -72,7 +69,6 @@ describe("ProviderWarningsService", () => {
|
||||
ProviderWarningsService,
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: ApiService, useValue: apiService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: Router, useValue: router },
|
||||
@@ -211,22 +207,7 @@ describe("ProviderWarningsService", () => {
|
||||
});
|
||||
|
||||
describe("showProviderSuspendedDialog$", () => {
|
||||
it("should not show dialog when feature flag is disabled", (done) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
apiService.send.mockResolvedValue({
|
||||
Suspension: { Resolution: "add_payment_method" },
|
||||
});
|
||||
|
||||
service.showProviderSuspendedDialog$(provider).subscribe({
|
||||
complete: () => {
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show dialog when no suspension warning exists", (done) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
apiService.send.mockResolvedValue({});
|
||||
|
||||
service.showProviderSuspendedDialog$(provider).subscribe({
|
||||
@@ -239,7 +220,6 @@ describe("ProviderWarningsService", () => {
|
||||
|
||||
it("should show add payment method dialog with cancellation date", (done) => {
|
||||
const cancelsAt = new Date(2024, 11, 31);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
apiService.send.mockResolvedValue({
|
||||
Suspension: {
|
||||
Resolution: "add_payment_method",
|
||||
@@ -282,7 +262,6 @@ describe("ProviderWarningsService", () => {
|
||||
});
|
||||
|
||||
it("should show add payment method dialog without cancellation date", (done) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
apiService.send.mockResolvedValue({
|
||||
Suspension: {
|
||||
Resolution: "add_payment_method",
|
||||
@@ -319,7 +298,6 @@ describe("ProviderWarningsService", () => {
|
||||
});
|
||||
|
||||
it("should show contact administrator dialog for contact_administrator resolution", (done) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
apiService.send.mockResolvedValue({
|
||||
Suspension: {
|
||||
Resolution: "contact_administrator",
|
||||
@@ -343,7 +321,6 @@ describe("ProviderWarningsService", () => {
|
||||
});
|
||||
|
||||
it("should show contact support dialog with action for contact_support resolution", (done) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
apiService.send.mockResolvedValue({
|
||||
Suspension: {
|
||||
Resolution: "contact_support",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
@@ -16,8 +15,6 @@ import {
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { ProviderId } from "@bitwarden/common/types/guid";
|
||||
@@ -39,7 +36,6 @@ export class ProviderWarningsService {
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private router: Router,
|
||||
@@ -61,12 +57,9 @@ export class ProviderWarningsService {
|
||||
refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next();
|
||||
|
||||
showProviderSuspendedDialog$ = (provider: Provider): Observable<void> =>
|
||||
combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover),
|
||||
this.getWarning$(provider, (response) => response.suspension),
|
||||
]).pipe(
|
||||
switchMap(async ([providerPortalTakeover, warning]) => {
|
||||
if (!providerPortalTakeover || !warning) {
|
||||
this.getWarning$(provider, (response) => response.suspension).pipe(
|
||||
switchMap(async (warning) => {
|
||||
if (!warning) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export default tseslint.config(
|
||||
"@angular-eslint/no-output-on-prefix": 0,
|
||||
"@angular-eslint/no-output-rename": "error",
|
||||
"@angular-eslint/no-outputs-metadata-property": "error",
|
||||
"@angular-eslint/prefer-inject": 0,
|
||||
"@angular-eslint/prefer-on-push-component-change-detection": "error",
|
||||
"@angular-eslint/prefer-output-emitter-ref": "error",
|
||||
"@angular-eslint/prefer-signals": "error",
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("PremiumUpgradeDialogComponent", () => {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
providedStorageGB: 1,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
@@ -58,6 +59,7 @@ describe("PremiumUpgradeDialogComponent", () => {
|
||||
users: 6,
|
||||
annualPrice: 40,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
providedStorageGB: 1,
|
||||
features: [{ key: "featureA", value: "Feature A" }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
providedStorageGB: 1,
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -16,6 +17,7 @@ import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/com
|
||||
export class PremiumComponent implements OnInit {
|
||||
isPremium$: Observable<boolean>;
|
||||
price = 10;
|
||||
storageProvidedGb = 0;
|
||||
refreshPromise: Promise<any>;
|
||||
cloudWebVaultUrl: string;
|
||||
|
||||
@@ -29,6 +31,7 @@ export class PremiumComponent implements OnInit {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
) {
|
||||
this.isPremium$ = accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
@@ -39,6 +42,9 @@ export class PremiumComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
|
||||
const premiumResponse = await this.billingApiService.getPremiumPlan();
|
||||
this.storageProvidedGb = premiumResponse.storage.provided;
|
||||
this.price = premiumResponse.seat.price;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InjectFlags, InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
import { InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
|
||||
export class ModalInjector implements Injector {
|
||||
constructor(
|
||||
@@ -12,8 +12,8 @@ export class ModalInjector implements Injector {
|
||||
options: InjectOptions & { optional?: false },
|
||||
): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue: null, options: InjectOptions): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | null): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: null): T;
|
||||
get(token: any, notFoundValue?: any): any;
|
||||
get(token: any, notFoundValue?: any, flags?: any): any {
|
||||
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from "@storybook/addon-docs";
|
||||
import { Meta, Story } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./input-password.stories.ts";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
import { action } from "storybook/actions";
|
||||
import { ZXCVBNResult } from "zxcvbn";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./registration-start.stories";
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
it("should log error and return early", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
|
||||
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
|
||||
|
||||
if (!ssoLoginEmail) {
|
||||
this.logService.error("SSO login email not found.");
|
||||
this.logService.debug("SSO login email not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: undefined,
|
||||
usePhishingBlocker: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -67,6 +67,7 @@ export class OrganizationData {
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -135,6 +136,7 @@ export class OrganizationData {
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = response.usePhishingBlocker;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -98,6 +98,7 @@ export class Organization {
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -162,6 +163,7 @@ export class Organization {
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
this.usePhishingBlocker = obj.usePhishingBlocker;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -39,6 +39,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -82,5 +83,6 @@ export class OrganizationResponse extends BaseResponse {
|
||||
);
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -135,5 +136,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse {
|
||||
seat: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
};
|
||||
storage: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
};
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse {
|
||||
class PurchasableResponse extends BaseResponse {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
provided: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse {
|
||||
if (typeof this.price !== "number" || isNaN(this.price)) {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
|
||||
}
|
||||
this.provided = this.getResponseProperty("Provided");
|
||||
if (typeof this.provided !== "number" || isNaN(this.provided)) {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'Provided' property");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
basePrice: 36,
|
||||
seatPrice: 0,
|
||||
additionalStoragePricePerGb: 4,
|
||||
providedStorageGB: 1,
|
||||
allowSeatAutoscale: false,
|
||||
maxSeats: 6,
|
||||
maxCollections: null,
|
||||
@@ -94,6 +95,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
basePrice: 0,
|
||||
seatPrice: 36,
|
||||
additionalStoragePricePerGb: 4,
|
||||
providedStorageGB: 1,
|
||||
allowSeatAutoscale: true,
|
||||
maxSeats: null,
|
||||
maxCollections: null,
|
||||
@@ -359,6 +361,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
providedStorageGB: 1,
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
@@ -383,6 +386,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPrice: mockFamiliesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockFamiliesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "premiumAccounts", value: "6 premium accounts" },
|
||||
{ key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" },
|
||||
@@ -456,6 +460,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
|
||||
expect(premiumTier.passwordManager.annualPrice).toEqual(10);
|
||||
expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4);
|
||||
expect(premiumTier.passwordManager.providedStorageGB).toEqual(1);
|
||||
|
||||
expect(familiesTier.passwordManager.annualPrice).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.basePrice,
|
||||
@@ -463,6 +468,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
);
|
||||
expect(familiesTier.passwordManager.providedStorageGB).toEqual(
|
||||
mockFamiliesPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
|
||||
done();
|
||||
});
|
||||
@@ -487,6 +495,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "secureItemSharing", value: "Secure item sharing" },
|
||||
{ key: "eventLogMonitoring", value: "Event log monitoring" },
|
||||
@@ -522,6 +531,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
|
||||
{ key: "passwordLessSso", value: "Passwordless SSO" },
|
||||
@@ -648,6 +658,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual(
|
||||
mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
);
|
||||
expect(teamsPasswordManager.providedStorageGB).toEqual(
|
||||
mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
|
||||
const enterprisePasswordManager = enterpriseTier.passwordManager as any;
|
||||
const enterpriseSecretsManager = enterpriseTier.secretsManager as any;
|
||||
@@ -657,6 +670,9 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual(
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
);
|
||||
expect(enterprisePasswordManager.providedStorageGB).toEqual(
|
||||
mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
expect(enterpriseSecretsManager.annualPricePerUser).toEqual(
|
||||
mockEnterprisePlan.SecretsManager.seatPrice,
|
||||
);
|
||||
@@ -729,6 +745,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "secureItemSharing", value: "Secure item sharing" },
|
||||
{ key: "eventLogMonitoring", value: "Event log monitoring" },
|
||||
@@ -764,6 +781,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
|
||||
{ key: "passwordLessSso", value: "Passwordless SSO" },
|
||||
|
||||
@@ -40,6 +40,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
*/
|
||||
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||
private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@@ -114,11 +115,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
provided: premiumPlan.storage.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||
provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB,
|
||||
}),
|
||||
),
|
||||
map((premiumPrices) => ({
|
||||
@@ -130,6 +133,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "standalone",
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
providedStorageGB: premiumPrices.provided,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
@@ -161,6 +165,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
@@ -214,6 +219,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
@@ -253,6 +259,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
|
||||
@@ -30,13 +30,19 @@ type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
@@ -52,6 +58,7 @@ type CustomPasswordManager = HasFeatures & {
|
||||
};
|
||||
|
||||
type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user