diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c500e59d536..f3b76ae462d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -2054,7 +2054,6 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2 flatpak xvfb dbus-x11 flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - flatpak install -y --user flathub - name: Install flatpak working-directory: apps/desktop/artifacts/linux/flatpak diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 3f7b7e326d9..5d37c00c2d9 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -91,7 +91,9 @@ jobs: apps/cli/bw-macos-${{ env.PKG_VERSION }}.zip, apps/cli/bw-macos-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-oss-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-oss-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bitwarden-cli.${{ env.PKG_VERSION }}.nupkg, apps/cli/bw_${{ env.PKG_VERSION }}_amd64.snap, apps/cli/bitwarden-cli-${{ env.PKG_VERSION }}-npm-build.zip" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c6d9d325e00..7944904c44a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2747,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -5966,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6127,6 +6127,10 @@ "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" }, + + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, "emailProtected": { "message": "Email protected" }, @@ -6134,4 +6138,4 @@ "message": "Individuals will need to enter the password to view this Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} \ No newline at end of file +} diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index e50a317e8a7..bc416d98634 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -4,70 +4,70 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CollectionView } from "../../content/components/common-types"; -import { NotificationType, NotificationTypes } from "../../enums/notification-type.enum"; +import { NotificationType } from "../../enums/notification-type.enum"; import AutofillPageDetails from "../../models/autofill-page-details"; /** - * @todo Remove Standard_ label when implemented as standard NotificationQueueMessage. + * Generic notification queue message structure. + * All notification types use this structure with type-specific data. */ -export interface Standard_NotificationQueueMessage { - // universal notification properties +export interface NotificationQueueMessage { domain: string; tab: chrome.tabs.Tab; launchTimestamp: number; expires: Date; wasVaultLocked: boolean; - - type: T; // NotificationType - data: D; // notification-specific data + type: T; + data: D; } -/** - * @todo Deprecate in favor of Standard_NotificationQueueMessage. - */ -interface NotificationQueueMessage { - type: NotificationTypes; - domain: string; - tab: chrome.tabs.Tab; - launchTimestamp: number; - expires: Date; - wasVaultLocked: boolean; -} +// Notification data type definitions +export type AddLoginNotificationData = { + username: string; + password: string; + uri: string; +}; -type ChangePasswordNotificationData = { +export type ChangePasswordNotificationData = { cipherIds: CipherView["id"][]; newPassword: string; }; -type AddChangePasswordNotificationQueueMessage = Standard_NotificationQueueMessage< +export type UnlockVaultNotificationData = never; + +export type AtRiskPasswordNotificationData = { + organizationName: string; + passwordChangeUri?: string; +}; + +// Notification queue message types using generic pattern +export type AddLoginQueueMessage = NotificationQueueMessage< + typeof NotificationType.AddLogin, + AddLoginNotificationData +>; + +export type AddChangePasswordNotificationQueueMessage = NotificationQueueMessage< typeof NotificationType.ChangePassword, ChangePasswordNotificationData >; -interface AddLoginQueueMessage extends NotificationQueueMessage { - type: "add"; - username: string; - password: string; - uri: string; -} +export type AddUnlockVaultQueueMessage = NotificationQueueMessage< + typeof NotificationType.UnlockVault, + UnlockVaultNotificationData +>; -interface AddUnlockVaultQueueMessage extends NotificationQueueMessage { - type: "unlock"; -} +export type AtRiskPasswordQueueMessage = NotificationQueueMessage< + typeof NotificationType.AtRiskPassword, + AtRiskPasswordNotificationData +>; -interface AtRiskPasswordQueueMessage extends NotificationQueueMessage { - type: "at-risk-password"; - organizationName: string; - passwordChangeUri?: string; -} - -type NotificationQueueMessageItem = +export type NotificationQueueMessageItem = | AddLoginQueueMessage | AddChangePasswordNotificationQueueMessage | AddUnlockVaultQueueMessage | AtRiskPasswordQueueMessage; -type LockedVaultPendingNotificationsData = { +export type LockedVaultPendingNotificationsData = { commandToRetry: { message: { command: string; @@ -80,26 +80,26 @@ type LockedVaultPendingNotificationsData = { target: string; }; -type AdjustNotificationBarMessageData = { +export type AdjustNotificationBarMessageData = { height: number; }; -type AddLoginMessageData = { +export type AddLoginMessageData = { username: string; password: string; url: string; }; -type UnlockVaultMessageData = { +export type UnlockVaultMessageData = { skipNotification?: boolean; }; /** - * @todo Extend generics to this type, see Standard_NotificationQueueMessage + * @todo Extend generics to this type, see NotificationQueueMessage * - use new `data` types as generic * - eliminate optional status of properties as needed per Notification Type */ -type NotificationBackgroundExtensionMessage = { +export type NotificationBackgroundExtensionMessage = { [key: string]: any; command: string; data?: Partial & @@ -119,7 +119,7 @@ type BackgroundMessageParam = { message: NotificationBackgroundExtensionMessage type BackgroundSenderParam = { sender: chrome.runtime.MessageSender }; type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; -type NotificationBackgroundExtensionMessageHandlers = { +export type NotificationBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; @@ -150,16 +150,3 @@ type NotificationBackgroundExtensionMessageHandlers = { bgGetActiveUserServerConfig: () => Promise; getWebVaultUrlForNotification: () => Promise; }; - -export { - AddChangePasswordNotificationQueueMessage, - AddLoginQueueMessage, - AddUnlockVaultQueueMessage, - NotificationQueueMessageItem, - LockedVaultPendingNotificationsData, - AdjustNotificationBarMessageData, - UnlockVaultMessageData, - AddLoginMessageData, - NotificationBackgroundExtensionMessage, - NotificationBackgroundExtensionMessageHandlers, -}; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 7d33d79a697..95d4111987b 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -126,9 +126,11 @@ describe("NotificationBackground", () => { it("returns a cipher view when passed an `AddLoginQueueMessage`", () => { const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "", tab: createChromeTabMock(), expires: new Date(), @@ -140,13 +142,13 @@ describe("NotificationBackground", () => { expect(cipherView.name).toEqual("example.com"); expect(cipherView.login).toEqual({ fido2Credentials: [], - password: message.password, + password: message.data.password, uris: [ { - _uri: message.uri, + _uri: message.data.uri, }, ], - username: message.username, + username: message.data.username, }); }); @@ -154,9 +156,11 @@ describe("NotificationBackground", () => { const folderId = "folder-id"; const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "example.com", tab: createChromeTabMock(), expires: new Date(), @@ -170,6 +174,44 @@ describe("NotificationBackground", () => { expect(cipherView.folderId).toEqual(folderId); }); + + it("removes 'www.' prefix from hostname when generating cipher name", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "https://www.example.com", + }, + domain: "www.example.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("example.com"); + }); + + it("uses domain as fallback when hostname cannot be extracted from uri", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "", + }, + domain: "fallback-domain.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("fallback-domain.com"); + }); }); describe("notification bar extension message handlers and triggers", () => { @@ -2544,8 +2586,11 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "updated-password", + data: { + username: "test", + password: "updated-password", + uri: "https://example.com", + }, wasVaultLocked: true, }); notificationBackground["notificationQueue"] = [queueMessage]; @@ -2559,7 +2604,7 @@ describe("NotificationBackground", () => { expect(updatePasswordSpy).toHaveBeenCalledWith( cipherView, - queueMessage.password, + queueMessage.data.password, message.edit, sender.tab, "testId", @@ -2631,9 +2676,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ @@ -2670,9 +2720,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ @@ -2716,9 +2771,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index e97672c1f0d..3713cd7c4c2 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -68,6 +68,7 @@ import { AddChangePasswordNotificationQueueMessage, AddLoginQueueMessage, AddLoginMessageData, + AtRiskPasswordQueueMessage, NotificationQueueMessageItem, LockedVaultPendingNotificationsData, NotificationBackgroundExtensionMessage, @@ -528,12 +529,14 @@ export default class NotificationBackground { this.removeTabFromNotificationQueue(tab); const launchTimestamp = new Date().getTime(); - const queueMessage: NotificationQueueMessageItem = { + const queueMessage: AtRiskPasswordQueueMessage = { domain, wasVaultLocked, type: NotificationType.AtRiskPassword, - passwordChangeUri, - organizationName: organization.name, + data: { + passwordChangeUri, + organizationName: organization.name, + }, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -612,10 +615,12 @@ export default class NotificationBackground { const launchTimestamp = new Date().getTime(); const message: AddLoginQueueMessage = { type: NotificationType.AddLogin, - username: loginInfo.username, - password: loginInfo.password, + data: { + username: loginInfo.username, + password: loginInfo.password, + uri: loginInfo.url, + }, domain: loginDomain, - uri: loginInfo.url, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -1291,16 +1296,23 @@ export default class NotificationBackground { // If the vault was locked, check if a cipher needs updating instead of creating a new one if (queueMessage.wasVaultLocked) { const allCiphers = await this.cipherService.getAllDecryptedForUrl( - queueMessage.uri, + queueMessage.data.uri, activeUserId, ); const existingCipher = allCiphers.find( (c) => - c.login.username != null && c.login.username.toLowerCase() === queueMessage.username, + c.login.username != null && + c.login.username.toLowerCase() === queueMessage.data.username, ); if (existingCipher != null) { - await this.updatePassword(existingCipher, queueMessage.password, edit, tab, activeUserId); + await this.updatePassword( + existingCipher, + queueMessage.data.password, + edit, + tab, + activeUserId, + ); return; } } @@ -1721,15 +1733,15 @@ export default class NotificationBackground { folderId?: string, ): CipherView { const uriView = new LoginUriView(); - uriView.uri = message.uri; + uriView.uri = message.data.uri; const loginView = new LoginView(); loginView.uris = [uriView]; - loginView.username = message.username; - loginView.password = message.password; + loginView.username = message.data.username; + loginView.password = message.data.password; const cipherView = new CipherView(); - cipherView.name = (Utils.getHostname(message.uri) || message.domain).replace(/^www\./, ""); + cipherView.name = (Utils.getHostname(message.data.uri) || message.domain).replace(/^www\./, ""); cipherView.folderId = folderId; cipherView.type = CipherType.Login; cipherView.login = loginView; diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index 73fc1e79ec5..e83f2b4b77c 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -3,6 +3,7 @@ import { html, TemplateResult } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; import { Spinner } from "../icons"; @@ -26,7 +27,7 @@ export function ActionButton({ fullWidth = true, }: ActionButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled && !isLoading) { + if (EventSecurity.isEventTrusted(event) && !disabled && !isLoading) { handleClick(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/badge-button.ts b/apps/browser/src/autofill/content/components/buttons/badge-button.ts index 3cdd453ee1a..98968d0b57b 100644 --- a/apps/browser/src/autofill/content/components/buttons/badge-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/badge-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; export type BadgeButtonProps = { @@ -23,7 +24,7 @@ export function BadgeButton({ username, }: BadgeButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/edit-button.ts b/apps/browser/src/autofill/content/components/buttons/edit-button.ts index ecbb736bb8e..88caae13590 100644 --- a/apps/browser/src/autofill/content/components/buttons/edit-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/edit-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { themes, typography, spacing } from "../constants/styles"; import { PencilSquare } from "../icons"; @@ -21,7 +22,7 @@ export function EditButton({ buttonAction, buttonText, disabled = false, theme } aria-label=${buttonText} class=${editButtonStyles({ disabled, theme })} @click=${(event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }} diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 36ea9c1f9d6..480b2acd0dd 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../../utils/event-security"; import { spacing, themes, typography } from "../../constants/styles"; export type NotificationConfirmationMessageProps = { @@ -127,7 +128,7 @@ const AdditionalMessageStyles = ({ theme }: { theme: Theme }) => css` `; function handleButtonKeyDown(event: KeyboardEvent, handleClick: () => void) { - if (event.key === "Enter" || event.key === " ") { + if (EventSecurity.isEventTrusted(event) && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); handleClick(); } diff --git a/apps/browser/src/autofill/content/components/option-selection/option-item.ts b/apps/browser/src/autofill/content/components/option-selection/option-item.ts index 6af6a2d6538..1cbabcb4f85 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-item.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-item.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { IconProps, Option } from "../common-types"; import { themes, spacing } from "../constants/styles"; @@ -29,6 +30,13 @@ export function OptionItem({ handleSelection, }: OptionItemProps) { const handleSelectionKeyUpProxy = (event: KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const listenedForKeys = new Set(["Enter", "Space"]); if (listenedForKeys.has(event.code) && event.target instanceof Element) { handleSelection(); @@ -37,6 +45,17 @@ export function OptionItem({ return; }; + const handleSelectionClickProxy = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + handleSelection(); + }; + const iconProps: IconProps = { color: themes[theme].text.main, theme }; const itemIcon = icon?.(iconProps); const ariaLabel = @@ -52,7 +71,7 @@ export function OptionItem({ title=${text} role="option" aria-label=${ariaLabel} - @click=${handleSelection} + @click=${handleSelectionClickProxy} @keyup=${handleSelectionKeyUpProxy} > ${itemIcon ? html`
${itemIcon}
` : nothing} diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index 58216b6c1b2..4c24a2fde8b 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { Option } from "../common-types"; import { themes, typography, scrollbarStyles, spacing } from "../constants/styles"; @@ -57,6 +58,10 @@ export function OptionItems({ } function handleMenuKeyUp(event: KeyboardEvent) { + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const items = [ ...(event.currentTarget as HTMLElement).querySelectorAll('[tabindex="0"]'), ]; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts index ee711456e9c..78c7d9f0646 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts @@ -4,6 +4,7 @@ import { property, state } from "lit/decorators.js"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { OptionSelectionButton } from "../buttons/option-selection-button"; import { Option } from "../common-types"; @@ -54,7 +55,7 @@ export class OptionSelection extends LitElement { private static currentOpenInstance: OptionSelection | null = null; private handleButtonClick = async (event: Event) => { - if (!this.disabled) { + if (EventSecurity.isEventTrusted(event) && !this.disabled) { const isOpening = !this.showMenu; if (isOpening) { diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index 874e1cc76ff..fb17874b0b7 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils"; +import { EventSecurity } from "../utils/event-security"; describe("ContentMessageHandler", () => { const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); @@ -19,6 +20,7 @@ describe("ContentMessageHandler", () => { ); beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./content-message-handler"); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index 63afc215923..874e760c4f8 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -1,6 +1,8 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; +import { EventSecurity } from "../utils/event-security"; + import { ContentMessageWindowData, ContentMessageWindowEventHandlers, @@ -92,7 +94,10 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr */ function handleWindowMessageEvent(event: MessageEvent) { const { source, data, origin } = event; - if (source !== window || !data?.command) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || source !== window || !data?.command) { return; } diff --git a/apps/browser/src/autofill/content/context-menu-handler.ts b/apps/browser/src/autofill/content/context-menu-handler.ts index d3926d57c9a..919ab5f1a3d 100644 --- a/apps/browser/src/autofill/content/context-menu-handler.ts +++ b/apps/browser/src/autofill/content/context-menu-handler.ts @@ -1,3 +1,5 @@ +import { EventSecurity } from "../utils/event-security"; + const inputTags = ["input", "textarea", "select"]; const labelTags = ["label", "span"]; const attributeKeys = ["id", "name", "label-aria", "placeholder"]; @@ -52,6 +54,12 @@ function isNullOrEmpty(s: string | null) { // We only have access to the element that's been clicked when the context menu is first opened. // Remember it for use later. document.addEventListener("contextmenu", (event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } clickedElement = event.target as HTMLElement; }); diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index e0ab45e9f84..19c1dbc8790 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BehaviorSubject, EmptyError, @@ -79,7 +77,7 @@ export type BrowserFido2Message = { sessionId: string } & ( } | { type: typeof BrowserFido2MessageTypes.PickCredentialResponse; - cipherId?: string; + cipherId: string; userVerified: boolean; } | { diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts index 1e99ac9df90..212fe6d8c89 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts @@ -10,6 +10,7 @@ import { createInitAutofillInlineMenuListMessageMock, } from "../../../../spec/autofill-mocks"; import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuList } from "./autofill-inline-menu-list"; @@ -28,6 +29,7 @@ describe("AutofillInlineMenuList", () => { const events: { eventName: any; callback: any }[] = []; beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); const oldEv = globalThis.addEventListener; globalThis.addEventListener = (eventName: any, callback: any) => { events.push({ eventName, callback }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index c13c523e30a..744e3579da1 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -10,6 +10,7 @@ import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background"; import { InlineMenuFillType } from "../../../../enums/autofill-overlay.enum"; import { buildSvgDomElement, specialCharacterToKeyMap, throttle } from "../../../../utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { creditCardIcon, globeIcon, @@ -203,7 +204,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -229,7 +237,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the unlock button. * Sends a message to the parent window to unlock the vault. */ - private handleUnlockButtonClick = () => { + private handleUnlockButtonClick = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "unlockVault" }); }; @@ -352,7 +367,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the fill generated password button. Triggers * a message to the background script to fill the generated password. */ - private handleFillGeneratedPasswordClick = () => { + private handleFillGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "fillGeneratedPassword" }); }; @@ -362,7 +384,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleFillGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -388,6 +419,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The click event. */ private handleRefreshGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + if (event) { (event.target as HTMLElement) .closest(".password-generator-actions") @@ -403,7 +441,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleRefreshGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -620,7 +667,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the new item button. * Sends a message to the parent window to add a new vault item. */ - private handleNewLoginVaultItemAction = () => { + private handleNewLoginVaultItemAction = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + let addNewCipherType = this.inlineMenuFillType; if (this.showInlineMenuAccountCreation) { @@ -958,7 +1012,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - () => this.triggerFillCipherClickEvent(cipher, usePasskey), + (event: Event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.triggerFillCipherClickEvent(cipher, usePasskey); + }, `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; @@ -990,7 +1053,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1018,7 +1088,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleNewItemButtonKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1063,11 +1140,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to view. */ private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => { - return this.useEventHandlersMemo( - () => - this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }), - `${cipher.id}-view-cipher-button-click-handler`, - ); + return this.useEventHandlersMemo((event: Event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }); + }, `${cipher.id}-view-cipher-button-click-handler`); }; /** @@ -1080,7 +1162,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts index 5df6e7cd190..e7f99b28ecc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts @@ -1,6 +1,7 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuPageElementWindowMessage, AutofillInlineMenuPageElementWindowMessageHandlers, @@ -163,7 +164,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement { */ private handleDocumentKeyDownEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["Tab", "Escape", "ArrowUp", "ArrowDown"]); - if (!listenedForKeys.has(event.code)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || !listenedForKeys.has(event.code)) { return; } diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 30170820a27..74ff0de6f5c 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -7,11 +7,7 @@

- {{ - (accountSwitcherEnabled$ | async) - ? ("excludedDomainsDescAlt" | i18n) - : ("excludedDomainsDesc" | i18n) - }} + {{ "excludedDomainsDesc" | i18n }}

diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 6714f749d2d..2316aef390e 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -15,7 +15,7 @@ import { FormArray, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { Observable, Subject, takeUntil } from "rxjs"; +import { Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -35,7 +35,6 @@ import { TypographyModule, } from "@bitwarden/components"; -import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -74,8 +73,6 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); - readonly accountSwitcherEnabled$: Observable = - this.accountSwitcherService.accountSwitchingEnabled$(); dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -96,7 +93,6 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - private accountSwitcherService: AccountSwitcherService, ) {} get domainForms() { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 0fb031b52e8..c9a522c6b8c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -23,6 +23,7 @@ import { sendMockExtensionMessage, } from "../spec/testing-utils"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; +import { EventSecurity } from "../utils/event-security"; import { AutoFillConstants } from "./autofill-constants"; import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; @@ -55,6 +56,9 @@ describe("AutofillOverlayContentService", () => { const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(async () => { + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); domQueryService = new DomQueryService(); domElementVisibilityService = new DomElementVisibilityService(); @@ -331,6 +335,8 @@ describe("AutofillOverlayContentService", () => { pageDetailsMock, ); jest.spyOn(globalThis.customElements, "define").mockImplementation(); + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); }); it("closes the autofill inline menu when the `Escape` key is pressed", () => { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 7ea89e114ab..eb02d05d671 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -45,6 +45,7 @@ import { sendExtensionMessage, throttle, } from "../utils"; +import { EventSecurity } from "../utils/event-security"; import { AutofillOverlayContentExtensionMessageHandlers, @@ -618,6 +619,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private handleSubmitButtonInteraction = (event: PointerEvent) => { if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || !this.submitElements.has(event.target as HTMLElement) || (event.type === "keyup" && !["Enter", "Space"].includes((event as unknown as KeyboardEvent).code)) @@ -703,6 +708,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param event - The keyup event. */ private handleFormFieldKeyupEvent = async (event: globalThis.KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const eventCode = event.code; if (eventCode === "Escape") { void this.sendExtensionMessage("closeAutofillInlineMenu", { diff --git a/apps/browser/src/autofill/utils/event-security.spec.ts b/apps/browser/src/autofill/utils/event-security.spec.ts new file mode 100644 index 00000000000..5cda484d4d2 --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.spec.ts @@ -0,0 +1,26 @@ +import { EventSecurity } from "./event-security"; + +describe("EventSecurity", () => { + describe("isEventTrusted", () => { + it("should call the event.isTrusted property", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const result = EventSecurity.isEventTrusted(testEvent); + + // In test environment, events are untrusted by default + expect(result).toBe(false); + expect(result).toBe(testEvent.isTrusted); + }); + + it("should be mockable with jest.spyOn", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const spy = jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + + const result = EventSecurity.isEventTrusted(testEvent); + + expect(result).toBe(true); + expect(spy).toHaveBeenCalledWith(testEvent); + + spy.mockRestore(); + }); + }); +}); diff --git a/apps/browser/src/autofill/utils/event-security.ts b/apps/browser/src/autofill/utils/event-security.ts new file mode 100644 index 00000000000..e53517058df --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.ts @@ -0,0 +1,13 @@ +/** + * Event security utilities for validating trusted events + */ +export class EventSecurity { + /** + * Validates that an event is trusted (originated from user agent) + * @param event - The event to validate + * @returns true if the event is trusted, false otherwise + */ + static isEventTrusted(event: Event): boolean { + return event.isTrusted; + } +} diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 88068987dd7..1c6421912ab 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,8 +1,6 @@ export type PhishingResource = { name?: string; - remoteUrl: string; - /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ - fallbackUrl: string; + primaryUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -20,8 +18,7 @@ export const PHISHING_RESOURCES: Record new Error("Invalid resource URL")); } - this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); - return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.primaryUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.primaryUrl))).pipe( switchMap((response) => { if (!response.ok || !response.body) { return throwError( @@ -322,33 +322,6 @@ export class PhishingDataService { return from(this.indexedDbService.saveUrlsFromStream(response.body)); }), - catchError((err: unknown) => { - this.logService.error( - `[PhishingDataService] Full dataset update failed using primary source ${err}`, - ); - this.logService.warning( - `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, - ); - // Try fallback URL - return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( - switchMap((fallbackResponse) => { - if (!fallbackResponse.ok || !fallbackResponse.body) { - return throwError( - () => - new Error( - `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, - ), - ); - } - - return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); - }), - catchError((fallbackError: unknown) => { - this.logService.error(`[PhishingDataService] Fallback source failed`); - return throwError(() => fallbackError); - }), - ); - }), ); } diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index e04d302ea2c..919d01f2d51 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -27,8 +27,8 @@ {{ button.label | i18n }} -
-
+
+
diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 5a40b72daff..f873d25641b 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -5,7 +5,7 @@ import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SvgModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule, BerryComponent } from "@bitwarden/components"; export type NavButton = { label: string; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule, BerryComponent], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 7fb466449f2..4e14d1171fd 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -78,13 +78,13 @@ import { ExportBrowserV2Component } from "../tools/popup/settings/export/export- import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component"; import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component"; import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component"; -import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; -import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component"; -import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; -import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro-carousel/intro-carousel.component"; -import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; -import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; -import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; +import { AddEditComponent } from "../vault/popup/components/vault/add-edit/add-edit.component"; +import { AssignCollections } from "../vault/popup/components/vault/assign-collections/assign-collections.component"; +import { AttachmentsComponent } from "../vault/popup/components/vault/attachments/attachments.component"; +import { IntroCarouselComponent } from "../vault/popup/components/vault/intro-carousel/intro-carousel.component"; +import { PasswordHistoryComponent } from "../vault/popup/components/vault/vault-password-history/vault-password-history.component"; +import { VaultComponent } from "../vault/popup/components/vault/vault.component"; +import { ViewComponent } from "../vault/popup/components/vault/view/view.component"; import { atRiskPasswordAuthGuard, canAccessAtRiskPasswords, @@ -93,13 +93,13 @@ import { import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component"; -import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; +import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component"; -import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component"; -import { MoreFromBitwardenPageV2Component } from "../vault/popup/settings/more-from-bitwarden-page-v2.component"; +import { FoldersComponent } from "../vault/popup/settings/folders.component"; +import { MoreFromBitwardenPageComponent } from "../vault/popup/settings/more-from-bitwarden-page.component"; import { TrashComponent } from "../vault/popup/settings/trash.component"; -import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; +import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component"; import { RouteElevation } from "./app-routing.animations"; import { @@ -214,7 +214,7 @@ const routes: Routes = [ }, { path: "view-cipher", - component: ViewV2Component, + component: ViewComponent, canActivate: [authGuard], data: { // Above "trash" @@ -223,20 +223,20 @@ const routes: Routes = [ }, { path: "cipher-password-history", - component: PasswordHistoryV2Component, + component: PasswordHistoryComponent, canActivate: [authGuard], data: { elevation: 4 } satisfies RouteDataProperties, }, { path: "add-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, { path: "edit-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { // Above "trash" @@ -247,7 +247,7 @@ const routes: Routes = [ }, { path: "attachments", - component: AttachmentsV2Component, + component: AttachmentsComponent, canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 4 } satisfies RouteDataProperties, }, @@ -301,13 +301,13 @@ const routes: Routes = [ }, { path: "vault-settings", - component: VaultSettingsV2Component, + component: VaultSettingsComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "folders", - component: FoldersV2Component, + component: FoldersComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -331,7 +331,7 @@ const routes: Routes = [ }, { path: "appearance", - component: AppearanceV2Component, + component: AppearanceComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, @@ -343,7 +343,7 @@ const routes: Routes = [ }, { path: "clone-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, @@ -635,7 +635,7 @@ const routes: Routes = [ }, { path: "more-from-bitwarden", - component: MoreFromBitwardenPageV2Component, + component: MoreFromBitwardenPageComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -696,7 +696,7 @@ const routes: Routes = [ }, { path: "vault", - component: VaultV2Component, + component: VaultComponent, canActivate: [authGuard], canDeactivate: [clearVaultStateGuard], data: { elevation: 0 } satisfies RouteDataProperties, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 19f2445b61d..5aa962d5cc3 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -1,19 +1,21 @@ - - {{ "unlockFeaturesWithPremium" | i18n }} - - - + @if (!(hasPremium$ | async)) { + + {{ "unlockFeaturesWithPremium" | i18n }} + + + + } @@ -23,14 +25,14 @@ - + {{ "accountSecurity" | i18n }} - +

{{ "autofill" | i18n }}

@@ -44,7 +46,7 @@
- + {{ "notifications" | i18n }} @@ -55,6 +57,7 @@ bit-item-content routerLink="/vault-settings" (click)="dismissBadge(NudgeType.EmptyVaultNudge)" + [truncate]="false" >
@@ -63,20 +66,18 @@ Currently can be only 1 item for notification. Will make this dynamic when more nudges are added --> - 1 + @if (showVaultBadge$ | async) { + 1 + }
- + {{ "appearance" | i18n }} @@ -85,7 +86,7 @@ @if (showAdminSettingsLink$ | async) { - +

{{ "admin" | i18n }}

@@ -101,30 +102,28 @@ } -
+ {{ "about" | i18n }} - +
-

{{ "downloadBitwardenOnAllDevices" | i18n }}

- 1 - +

{{ "downloadBitwardenApps" | i18n }}

+ @if (showDownloadBitwardenNudge$ | async) { + 1 + + }
- + {{ "moreFromBitwarden" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html similarity index 74% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html index f8238a188e0..d4495cf4c92 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html @@ -41,24 +41,6 @@ - @if (isEditMode) { - @if ((archiveFlagEnabled$ | async) && isCipherArchived) { - - } - @if ((userCanArchive$ | async) && canCipherBeArchived) { - - } - } @if (canDeleteCipher$ | async) { @if (!decryptionFailure) { - + @if (canAutofill && showAutofill()) { - - + } + @if (showViewOption()) { - + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index d7de51ad20f..ef4c4a111b6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, input, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -35,7 +35,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; import { AutofillConfirmationDialogComponent, AutofillConfirmationDialogResult, @@ -76,22 +76,17 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show view item menu option. Used when something else is - * assigned as the primary action for the item, such as autofill. + * Flag to show the autofill menu option. + * When true, the "Autofill" option appears in the menu. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - showViewOption = false; + readonly showAutofill = input(false, { transform: booleanAttribute }); /** - * Flag to hide the autofill menu options. Used for items that are - * already in the autofill list suggestion. + * Flag to show the view menu option. + * When true, the "View" option appears in the menu. + * Used when the primary action is autofill (so users can view without autofilling). */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - hideAutofillOptions = false; + readonly showViewOption = input(false, { transform: booleanAttribute }); protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts index 48e87e2d192..a20724e3160 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts @@ -21,13 +21,13 @@ import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitward import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; +import { NewItemDropdownComponent, NewItemInitialValues } from "./new-item-dropdown.component"; -describe("NewItemDropdownV2Component", () => { - let component: NewItemDropdownV2Component; - let fixture: ComponentFixture; +describe("NewItemDropdownComponent", () => { + let component: NewItemDropdownComponent; + let fixture: ComponentFixture; let dialogServiceMock: jest.Mocked; - let browserApiMock: jest.Mocked; + const browserApiMock: jest.Mocked = mock(); let restrictedItemTypesServiceMock: jest.Mocked; const mockTab = { url: "https://example.com" }; @@ -62,7 +62,7 @@ describe("NewItemDropdownV2Component", () => { ButtonModule, MenuModule, NoItemsModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ], providers: [ { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -80,7 +80,7 @@ describe("NewItemDropdownV2Component", () => { }); beforeEach(() => { - fixture = TestBed.createComponent(NewItemDropdownV2Component); + fixture = TestBed.createComponent(NewItemDropdownComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts index 004980db181..aa43743960b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts @@ -15,7 +15,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; export interface NewItemInitialValues { folderId?: string; @@ -27,10 +27,10 @@ export interface NewItemInitialValues { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-new-item-dropdown", - templateUrl: "new-item-dropdown-v2.component.html", + templateUrl: "new-item-dropdown.component.html", imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], }) -export class NewItemDropdownV2Component implements OnInit { +export class NewItemDropdownComponent implements OnInit { cipherType = CipherType; private tab?: chrome.tabs.Tab; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html similarity index 95% rename from apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html index 1ab162b56fb..09b4cb2b461 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html @@ -1,6 +1,6 @@
- +
- - - - + @if (showFillTextOnHover()) { + + + {{ "fill" | i18n }} + + + } + @if (showAutofillBadge()) { + + + + } + @if (showLaunchButton() && CipherViewLikeUtils.canLaunch(cipher)) { + + + + } diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts new file mode 100644 index 00000000000..eda84265e90 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts @@ -0,0 +1,332 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CompactModeService, DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupSectionService } from "../../../services/vault-popup-section.service"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; + +import { VaultListItemsContainerComponent } from "./vault-list-items-container.component"; + +describe("VaultListItemsContainerComponent", () => { + let fixture: ComponentFixture; + let component: VaultListItemsContainerComponent; + + const featureFlag$ = new BehaviorSubject(false); + const currentTabIsOnBlocklist$ = new BehaviorSubject(false); + + const mockCipher = { + id: "cipher-1", + name: "Test Login", + type: CipherType.Login, + login: { + username: "user@example.com", + uris: [{ uri: "https://example.com", match: null }], + }, + favorite: false, + reprompt: 0, + organizationId: null, + collectionIds: [], + edit: true, + viewPassword: true, + } as any; + + const configService = { + getFeatureFlag$: jest.fn().mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }), + }; + + const vaultPopupAutofillService = { + currentTabIsOnBlocklist$: currentTabIsOnBlocklist$.asObservable(), + doAutofill: jest.fn(), + }; + + const compactModeService = { + enabled$: of(false), + }; + + const vaultPopupSectionService = { + getOpenDisplayStateForSection: jest.fn().mockReturnValue(() => true), + updateSectionOpenStoredState: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + featureFlag$.next(false); + currentTabIsOnBlocklist$.next(false); + + await TestBed.configureTestingModule({ + imports: [VaultListItemsContainerComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: VaultPopupAutofillService, useValue: vaultPopupAutofillService }, + { provide: CompactModeService, useValue: compactModeService }, + { provide: VaultPopupSectionService, useValue: vaultPopupSectionService }, + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: CipherService, useValue: mock() }, + { provide: Router, useValue: { navigate: jest.fn() } }, + { provide: PlatformUtilsService, useValue: { getAutofillKeyboardShortcut: () => "" } }, + { provide: DialogService, useValue: mock() }, + { provide: PasswordRepromptService, useValue: mock() }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(VaultListItemsContainerComponent); + component = fixture.componentInstance; + }); + + describe("Updated item action feature flag", () => { + describe("when feature flag is OFF", () => { + beforeEach(() => { + featureFlag$.next(false); + fixture.detectChanges(); + }); + + it("should not show fill text on hover", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should show autofill badge when showAutofillButton is true and primaryActionAutofill is false", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(true); + }); + + it("should hide autofill badge when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should show launch button when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should hide launch button when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show autofill in menu when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when feature flag is ON", () => { + beforeEach(() => { + featureFlag$.next(true); + fixture.detectChanges(); + }); + + it("should show fill text on hover for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(true); + }); + + it("should not show fill text on hover for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should not show autofill badge", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should hide launch button for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show launch button for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should show autofill in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when current URI is blocked", () => { + beforeEach(() => { + currentTabIsOnBlocklist$.next(true); + fixture.detectChanges(); + }); + + it("should not autofill on select even when feature flag is ON and isAutofillList is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + + it("should not autofill on select even when primaryActionAutofill is true", () => { + featureFlag$.next(false); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + }); + + describe("cipherItemTitleKey", () => { + it("should return autofillTitle when canAutofill is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("autofillTitleWithField"); + }); + + it("should return viewItemTitle when canAutofill is false", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("viewItemTitleWithField"); + }); + + it("should return title without WithField when cipher has no username", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const cipherWithoutUsername = { + ...mockCipher, + login: { ...mockCipher.login, username: null }, + } as PopupCipherViewLike; + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(cipherWithoutUsername); + + expect(result).toBe("viewItemTitle"); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts similarity index 72% rename from apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts index 469247f9692..fb8d20c5cf6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts @@ -21,6 +21,8 @@ import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; @@ -88,8 +90,15 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options export class VaultListItemsContainerComponent implements AfterViewInit { private compactModeService = inject(CompactModeService); private vaultPopupSectionService = inject(VaultPopupSectionService); + private configService = inject(ConfigService); protected CipherViewLikeUtils = CipherViewLikeUtils; + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; @@ -136,24 +145,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit { */ private viewCipherTimeout?: number; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - ciphers = input([]); + readonly ciphers = input([]); /** * If true, we will group ciphers by type (Login, Card, Identity) * within subheadings in a single container, converted to a WritableSignal. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - groupByType = input(false); + readonly groupByType = input(false); /** * Computed signal for a grouped list of ciphers with an optional header */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherGroups = computed< + readonly cipherGroups = computed< { subHeaderKey?: string; ciphers: PopupCipherViewLike[]; @@ -195,9 +198,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Title for the vault list item section. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - title = input(undefined); + readonly title = input(undefined); /** * Optionally allow the items to be collapsed. @@ -205,24 +206,20 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The key must be added to the state definition in `vault-popup-section.service.ts` since the * collapsed state is stored locally. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - collapsibleKey = input(undefined); + readonly collapsibleKey = input(undefined); /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - description = input(undefined); + + readonly description = input(undefined); /** * Option to show a refresh button in the section header. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showRefresh = input(false, { transform: booleanAttribute }); + + readonly showRefresh = input(false, { transform: booleanAttribute }); /** * Event emitted when the refresh button is clicked. @@ -235,71 +232,124 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Flag indicating that the current tab location is blocked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); + readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); /** * Resolved i18n key to use for suggested cipher items */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherItemTitleKey = computed(() => { + readonly cipherItemTitleKey = computed(() => { return (cipher: CipherViewLike) => { const login = CipherViewLikeUtils.getLogin(cipher); const hasUsername = login?.username != null; - const key = - this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? "autofillTitle" - : "viewItemTitle"; + // Use autofill title when autofill is the primary action + const key = this.canAutofill() ? "autofillTitle" : "viewItemTitle"; return hasUsername ? `${key}WithField` : key; }; }); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to show the autofill button for each item. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showAutofillButton = input(false, { transform: booleanAttribute }); + readonly showAutofillButton = input(false, { transform: booleanAttribute }); /** - * Flag indicating whether the suggested cipher item autofill button should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Whether to show the autofill badge button (old behavior). + * Only shown when feature flag is disabled AND conditions are met. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillButton = computed( - () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(), + readonly showAutofillBadge = computed( + () => !this.simplifiedItemActionEnabled() && !this.hideAutofillButton(), ); /** - * Flag indicating whether the cipher item autofill menu options should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the cipher item autofill menu options should be shown or not. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton()); + readonly hideAutofillMenuOptions = computed( + () => this.currentUriIsBlocked() || this.showAutofillButton(), + ); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to perform autofill operation as the primary action for autofill suggestions. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - primaryActionAutofill = input(false, { transform: booleanAttribute }); + readonly primaryActionAutofill = input(false, { transform: booleanAttribute }); + + /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the suggested cipher item autofill button should be shown or not. + * Used when feature flag is disabled. + */ + readonly hideAutofillButton = computed( + () => !this.showAutofillButton() || this.currentUriIsBlocked() || this.primaryActionAutofill(), + ); + + /** + * Option to mark this container as an autofill list. + */ + readonly isAutofillList = input(false, { transform: booleanAttribute }); + + /** + * Computed property whether the cipher action may perform autofill. + * When feature flag is enabled, uses isAutofillList. + * When feature flag is disabled, uses primaryActionAutofill. + */ + readonly canAutofill = computed(() => { + if (this.currentUriIsBlocked()) { + return false; + } + return this.isAutofillList() + ? this.simplifiedItemActionEnabled() + : this.primaryActionAutofill(); + }); + + /** + * Whether to show the "Fill" text on hover. + * Only shown when feature flag is enabled AND this is an autofill list. + */ + readonly showFillTextOnHover = computed( + () => this.simplifiedItemActionEnabled() && this.canAutofill(), + ); + + /** + * Whether to show the launch button. + */ + readonly showLaunchButton = computed(() => + this.simplifiedItemActionEnabled() ? !this.isAutofillList() : !this.showAutofillButton(), + ); + + /** + * Whether to show the "Autofill" option in the more options menu. + * New behavior: show for non-autofill list items. + * Old behavior: show when not hidden by hideAutofillMenuOptions. + */ + readonly showAutofillInMenu = computed(() => + this.simplifiedItemActionEnabled() ? !this.canAutofill() : !this.hideAutofillMenuOptions(), + ); + + /** + * Whether to show the "View" option in the more options menu. + * New behavior: show for autofill list items (since click = autofill). + * Old behavior: show when primary action is autofill. + */ + readonly showViewInMenu = computed(() => + this.simplifiedItemActionEnabled() ? this.isAutofillList() : this.primaryActionAutofill(), + ); /** * Remove the bottom margin from the bit-section in this component * (used for containers at the end of the page where bottom margin is not needed) */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableSectionMargin = input(false, { transform: booleanAttribute }); + readonly disableSectionMargin = input(false, { transform: booleanAttribute }); /** * Remove the description margin */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableDescriptionMargin = input(false, { transform: booleanAttribute }); + readonly disableDescriptionMargin = input(false, { transform: booleanAttribute }); /** * The tooltip text for the organization icon for ciphers that belong to an organization. @@ -313,9 +363,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return collections[0]?.name; } - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - protected autofillShortcutTooltip = signal(undefined); + protected readonly autofillShortcutTooltip = signal(undefined); constructor( private i18nService: I18nService, @@ -340,10 +388,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { } } - primaryActionOnSelect(cipher: PopupCipherViewLike) { - return this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? this.doAutofill(cipher) - : this.onViewCipher(cipher); + onCipherSelect(cipher: PopupCipherViewLike) { + return this.canAutofill() ? this.doAutofill(cipher) : this.onViewCipher(cipher); } /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts similarity index 90% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts index 838ce2e9426..3b8a6db25b1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts @@ -16,10 +16,10 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; -import { PasswordHistoryV2Component } from "./vault-password-history-v2.component"; +import { PasswordHistoryComponent } from "./vault-password-history.component"; -describe("PasswordHistoryV2Component", () => { - let fixture: ComponentFixture; +describe("PasswordHistoryComponent", () => { + let fixture: ComponentFixture; const params$ = new Subject(); const mockUserId = "acct-1" as UserId; @@ -40,7 +40,7 @@ describe("PasswordHistoryV2Component", () => { getCipher.mockClear(); await TestBed.configureTestingModule({ - imports: [PasswordHistoryV2Component], + imports: [PasswordHistoryComponent], providers: [ { provide: WINDOW, useValue: window }, { provide: PlatformUtilsService, useValue: mock() }, @@ -56,7 +56,7 @@ describe("PasswordHistoryV2Component", () => { ], }).compileComponents(); - fixture = TestBed.createComponent(PasswordHistoryV2Component); + fixture = TestBed.createComponent(PasswordHistoryComponent); fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts index 7b9f358c01c..08e17d61acd 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts @@ -21,8 +21,8 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach // 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: "vault-password-history-v2", - templateUrl: "vault-password-history-v2.component.html", + selector: "vault-password-history", + templateUrl: "vault-password-history.component.html", imports: [ JslibModule, PopupPageComponent, @@ -32,7 +32,7 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach NgIf, ], }) -export class PasswordHistoryV2Component implements OnInit { +export class PasswordHistoryComponent implements OnInit { protected cipher: CipherView; constructor( diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html rename to apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts similarity index 89% rename from apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts index ca73a7332ee..ef8df2d2c6a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts @@ -11,18 +11,18 @@ import { SearchModule } from "@bitwarden/components"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; -import { VaultV2SearchComponent } from "./vault-v2-search.component"; +import { VaultSearchComponent } from "./vault-search.component"; -describe("VaultV2SearchComponent", () => { - let component: VaultV2SearchComponent; - let fixture: ComponentFixture; +describe("VaultSearchComponent", () => { + let component: VaultSearchComponent; + let fixture: ComponentFixture; const searchText$ = new BehaviorSubject(""); const loading$ = new BehaviorSubject(false); const applyFilter = jest.fn(); const createComponent = () => { - fixture = TestBed.createComponent(VaultV2SearchComponent); + fixture = TestBed.createComponent(VaultSearchComponent); component = fixture.componentInstance; fixture.detectChanges(); }; @@ -31,7 +31,7 @@ describe("VaultV2SearchComponent", () => { applyFilter.mockClear(); await TestBed.configureTestingModule({ - imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], + imports: [VaultSearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], providers: [ { provide: VaultPopupItemsService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.ts similarity index 95% rename from apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.ts index 3419bd30ea0..9ce26a6310d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.ts @@ -24,10 +24,10 @@ import { VaultPopupLoadingService } from "../../../services/vault-popup-loading. // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [CommonModule, SearchModule, JslibModule, FormsModule], - selector: "app-vault-v2-search", - templateUrl: "vault-v2-search.component.html", + selector: "app-vault-search", + templateUrl: "vault-search.component.html", }) -export class VaultV2SearchComponent { +export class VaultSearchComponent { searchText: string = ""; private searchText$ = new Subject(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault.component.html similarity index 99% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault.component.html index f0a6b0d6000..28abb92b8a9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault.component.html @@ -74,7 +74,7 @@
- + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.spec.ts index a956b2fe68b..70affd73ef3 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts @@ -50,10 +50,10 @@ import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-passw import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; -import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +import { NewItemDropdownComponent } from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component"; -import { VaultV2Component } from "./vault-v2.component"; +import { VaultComponent } from "./vault.component"; @Component({ selector: "popup-header", @@ -66,12 +66,12 @@ export class PopupHeaderStubComponent { } @Component({ - selector: "app-vault-header-v2", + selector: "app-vault-header", standalone: true, template: "", changeDetection: ChangeDetectionStrategy.OnPush, }) -export class VaultHeaderV2StubComponent {} +export class VaultHeaderStubComponent {} @Component({ selector: "app-current-account", @@ -158,8 +158,8 @@ const autoConfirmDialogSpy = jest jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); -describe("VaultV2Component", () => { - let component: VaultV2Component; +describe("VaultComponent", () => { + let component: VaultComponent; interface FakeAccount { id: string; @@ -242,7 +242,7 @@ describe("VaultV2Component", () => { beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ - imports: [VaultV2Component, RouterTestingModule], + imports: [VaultComponent, RouterTestingModule], providers: [ provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, @@ -298,13 +298,13 @@ describe("VaultV2Component", () => { schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - TestBed.overrideComponent(VaultV2Component, { + TestBed.overrideComponent(VaultComponent, { remove: { imports: [ PopupHeaderComponent, - VaultHeaderV2Component, + VaultHeaderComponent, CurrentAccountComponent, - NewItemDropdownV2Component, + NewItemDropdownComponent, PopOutComponent, BlockedInjectionBanner, AtRiskPasswordCalloutComponent, @@ -318,7 +318,7 @@ describe("VaultV2Component", () => { add: { imports: [ PopupHeaderStubComponent, - VaultHeaderV2StubComponent, + VaultHeaderStubComponent, CurrentAccountStubComponent, NewItemDropdownStubComponent, PopOutStubComponent, @@ -331,7 +331,7 @@ describe("VaultV2Component", () => { }, }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; }); @@ -393,7 +393,7 @@ describe("VaultV2Component", () => { }); it("passes popup-page scroll region element to scroll position service", fakeAsync(() => { - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; @@ -491,7 +491,7 @@ describe("VaultV2Component", () => { of(type === NudgeType.PremiumUpgrade), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -524,7 +524,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.EmptyVaultNudge); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -541,7 +541,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.HasVaultItems); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -559,7 +559,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -575,7 +575,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -591,7 +591,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -602,7 +602,7 @@ describe("VaultV2Component", () => { it("does not render app-autofill-vault-list-items or favorites item container when hasSearchText$ is true", () => { itemsSvc.hasSearchText$.next(true); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -628,7 +628,7 @@ describe("VaultV2Component", () => { itemsSvc.hasSearchText$.next(false); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -655,7 +655,7 @@ describe("VaultV2Component", () => { filtersSvc.numberOfAppliedFilters$.next(0); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -679,7 +679,7 @@ describe("VaultV2Component", () => { itemsSvc.hasSearchText$.next(true); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -704,7 +704,7 @@ describe("VaultV2Component", () => { filtersSvc.numberOfAppliedFilters$.next(1); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -735,7 +735,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -754,7 +754,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -773,7 +773,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -792,7 +792,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault.component.ts similarity index 97% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.ts index a5a74eb8ab8..281abc5f180 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.ts @@ -71,10 +71,10 @@ import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-l import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { - NewItemDropdownV2Component, + NewItemDropdownComponent, NewItemInitialValues, -} from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +} from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "."; @@ -90,7 +90,7 @@ type VaultState = UnionOfValues; // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault", - templateUrl: "vault-v2.component.html", + templateUrl: "vault.component.html", imports: [ BlockedInjectionBanner, PopupPageComponent, @@ -103,9 +103,9 @@ type VaultState = UnionOfValues; AutofillVaultListItemsComponent, VaultListItemsContainerComponent, ButtonModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ScrollingModule, - VaultHeaderV2Component, + VaultHeaderComponent, AtRiskPasswordCalloutComponent, SpotlightComponent, RouterModule, @@ -116,7 +116,7 @@ type VaultState = UnionOfValues; ], providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }], }) -export class VaultV2Component implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { NudgeType = NudgeType; cipherType = CipherType; private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault/view/view.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html rename to apps/browser/src/vault/popup/components/vault/view/view.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts similarity index 98% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts index 7e57cd69ba1..5c94af0205d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts @@ -45,19 +45,19 @@ import { import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; -import { ViewV2Component } from "./view-v2.component"; +import { ViewComponent } from "./view.component"; // 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. // Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the // `BrowserTotpCaptureService` where jest would not load the file in the first place. jest.mock("qrcode-parser", () => {}); -describe("ViewV2Component", () => { - let component: ViewV2Component; - let fixture: ComponentFixture; +describe("ViewComponent", () => { + let component: ViewComponent; + let fixture: ComponentFixture; const params$ = new Subject(); const mockNavigate = jest.fn(); const collect = jest.fn().mockResolvedValue(null); @@ -124,7 +124,7 @@ describe("ViewV2Component", () => { cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ - imports: [ViewV2Component], + imports: [ViewComponent], providers: [ { provide: Router, useValue: { navigate: mockNavigate } }, { provide: CipherService, useValue: mockCipherService }, @@ -231,7 +231,7 @@ describe("ViewV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(ViewV2Component); + fixture = TestBed.createComponent(ViewComponent); component = fixture.componentInstance; fixture.detectChanges(); (component as any).showFooter$ = of(true); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.ts similarity index 96% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.ts index f57b3e2d7f1..d63cd5920a1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.ts @@ -56,17 +56,16 @@ import { sendExtensionMessage } from "../../../../../autofill/utils/index"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window"; -import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit-v2.component"; - -import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; -import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; -import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; +import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit.component"; /** * The types of actions that can be triggered when loading the view vault item popout via the @@ -83,8 +82,8 @@ type LoadAction = // 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-view-v2", - templateUrl: "view-v2.component.html", + selector: "app-view", + templateUrl: "view.component.html", imports: [ CommonModule, SearchModule, @@ -107,7 +106,7 @@ type LoadAction = { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) -export class ViewV2Component { +export class ViewComponent { private activeUserId: UserId; headerText: string; diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts index 7ead8576b37..633a3b3295e 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { RouterStateSnapshot } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -42,7 +42,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -56,7 +56,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -67,7 +67,7 @@ describe("clearVaultStateGuard", () => { it("should not clear vault state when not changing states", () => { const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, null), + clearVaultStateGuard({} as VaultComponent, null, null, null), ); expect(result).toBe(true); diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts index 2a87db6e903..5258c7cd741 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts @@ -1,7 +1,7 @@ import { inject } from "@angular/core"; import { CanDeactivateFn } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -10,8 +10,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte * This ensures the search and filter state is reset when navigating between different tabs, * except viewing or editing a cipher. */ -export const clearVaultStateGuard: CanDeactivateFn = ( - component: VaultV2Component, +export const clearVaultStateGuard: CanDeactivateFn = ( + component: VaultComponent, currentRoute, currentState, nextState, diff --git a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts index ecba9aa1413..9e67953c251 100644 --- a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts +++ b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts @@ -7,7 +7,7 @@ import { firstValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { CipherFormGenerationService } from "@bitwarden/vault"; -import { VaultGeneratorDialogComponent } from "../components/vault-v2/vault-generator-dialog/vault-generator-dialog.component"; +import { VaultGeneratorDialogComponent } from "../components/vault/vault-generator-dialog/vault-generator-dialog.component"; @Injectable() export class BrowserCipherFormGenerationService implements CipherFormGenerationService { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 093fdbfb66d..845dfd6f4b1 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -69,7 +69,7 @@ describe("VaultPopupItemsService", () => { const accountServiceMock = mockAccountServiceWith(userId); const configServiceMock = mock(); const cipherArchiveServiceMock = mock(); - cipherArchiveServiceMock.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveServiceMock.hasArchiveFlagEnabled$ = of(true); const restrictedItemTypesService = { restricted$: new BehaviorSubject([]), diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 7ccfc834c87..93f2734e6b8 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -135,24 +135,23 @@ export class VaultPopupItemsService { shareReplay({ refCount: true, bufferSize: 1 }), ); - private userCanArchive$ = this.activeUserId$.pipe( - switchMap((userId) => { - return this.cipherArchiveService.userCanArchive$(userId); - }), - ); - private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.decryptedCollections$, this.userCanArchive$]).pipe( - map(([organizations, collections, canArchive]) => { + combineLatest([ + this.organizations$, + this.decryptedCollections$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]).pipe( + map(([organizations, collections, archiveFlag]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers .filter( (c) => !CipherViewLikeUtils.isDeleted(c) && - (!canArchive || !CipherViewLikeUtils.isArchived(c)), + (!archiveFlag || !CipherViewLikeUtils.isArchived(c)), ) + .map((cipher) => { (cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map( (colId) => collectionMap[colId as CollectionId], diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance.component.html similarity index 84% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.html rename to apps/browser/src/vault/popup/settings/appearance.component.html index b58316a8d64..d87c0640f52 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html +++ b/apps/browser/src/vault/popup/settings/appearance.component.html @@ -50,16 +50,18 @@ - + {{ "showQuickCopyActions" | i18n }} - - - - {{ "clickToAutofill" | i18n }} - - + @if (!simplifiedItemActionEnabled()) { + + + + {{ "clickToAutofill" | i18n }} + + + } diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts similarity index 77% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/appearance.component.spec.ts index 9e1beab5787..465b78e232d 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts @@ -1,10 +1,12 @@ import { Component, Input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -20,7 +22,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service"; import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.service"; -import { AppearanceV2Component } from "./appearance-v2.component"; +import { AppearanceComponent } from "./appearance.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,9 +51,9 @@ class MockPopupPageComponent { @Input() loading: boolean; } -describe("AppearanceV2Component", () => { - let component: AppearanceV2Component; - let fixture: ComponentFixture; +describe("AppearanceComponent", () => { + let component: AppearanceComponent; + let fixture: ComponentFixture; const showFavicons$ = new BehaviorSubject(true); const enableBadgeCounter$ = new BehaviorSubject(true); @@ -59,7 +61,7 @@ describe("AppearanceV2Component", () => { const enableRoutingAnimation$ = new BehaviorSubject(true); const enableCompactMode$ = new BehaviorSubject(false); const showQuickCopyActions$ = new BehaviorSubject(false); - const clickItemsToAutofillVaultView$ = new BehaviorSubject(false); + const featureFlag$ = new BehaviorSubject(false); const setSelectedTheme = jest.fn().mockResolvedValue(undefined); const setShowFavicons = jest.fn().mockResolvedValue(undefined); const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined); @@ -78,11 +80,20 @@ describe("AppearanceV2Component", () => { setShowFavicons.mockClear(); setEnableBadgeCounter.mockClear(); setEnableRoutingAnimation.mockClear(); + setClickItemsToAutofillVaultView.mockClear(); + + const configService = mock(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }); await TestBed.configureTestingModule({ - imports: [AppearanceV2Component], + imports: [AppearanceComponent], providers: [ - { provide: ConfigService, useValue: mock() }, + { provide: ConfigService, useValue: configService }, { provide: PlatformUtilsService, useValue: mock() }, { provide: MessagingService, useValue: mock() }, { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -114,13 +125,13 @@ describe("AppearanceV2Component", () => { { provide: VaultSettingsService, useValue: { - clickItemsToAutofillVaultView$, + clickItemsToAutofillVaultView$: of(false), setClickItemsToAutofillVaultView, }, }, ], }) - .overrideComponent(AppearanceV2Component, { + .overrideComponent(AppearanceComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent], }, @@ -130,7 +141,7 @@ describe("AppearanceV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(AppearanceV2Component); + fixture = TestBed.createComponent(AppearanceComponent); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -193,11 +204,40 @@ describe("AppearanceV2Component", () => { expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide"); }); + }); - it("updates the click items to autofill vault view setting", () => { - component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + describe("PM31039ItemActionInExtension feature flag", () => { + describe("when set to OFF", () => { + it("should show clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(false); + fixture.detectChanges(); - expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).not.toBeNull(); + }); + + it("should update the clickItemsToAutofillVaultView setting when changed", () => { + featureFlag$.next(false); + fixture.detectChanges(); + + component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + + expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + }); + }); + + describe("when set to ON", () => { + it("should hide clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(true); + fixture.detectChanges(); + + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).toBeNull(); + }); }); }); }); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance.component.ts similarity index 92% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.ts rename to apps/browser/src/vault/popup/settings/appearance.component.ts index e02ccf25f3e..47aa1804efc 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.ts @@ -2,14 +2,16 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -36,7 +38,7 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./appearance-v2.component.html", + templateUrl: "./appearance.component.html", imports: [ CommonModule, JslibModule, @@ -52,11 +54,18 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto PermitCipherDetailsPopoverComponent, ], }) -export class AppearanceV2Component implements OnInit { +export class AppearanceComponent implements OnInit { private compactModeService = inject(PopupCompactModeService); private copyButtonsService = inject(VaultPopupCopyButtonsService); private popupSizeService = inject(PopupSizeService); private i18nService = inject(I18nService); + private configService = inject(ConfigService); + + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); appearanceForm = this.formBuilder.group({ enableFavicon: false, diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index a34609bd8f8..336d9be6d16 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -42,7 +42,7 @@ import { import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault-v2/add-edit/add-edit-v2.component"; +import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault/add-edit/add-edit.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/folders-v2.component.html rename to apps/browser/src/vault/popup/settings/folders.component.html diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/folders.component.spec.ts index 3cb5503ed89..678e6d3f10e 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.spec.ts @@ -19,7 +19,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; -import { FoldersV2Component } from "./folders-v2.component"; +import { FoldersComponent } from "./folders.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -48,9 +48,9 @@ class MockPopupFooterComponent { @Input() pageTitle: string = ""; } -describe("FoldersV2Component", () => { - let component: FoldersV2Component; - let fixture: ComponentFixture; +describe("FoldersComponent", () => { + let component: FoldersComponent; + let fixture: ComponentFixture; const folderViews$ = new BehaviorSubject([]); const open = jest.spyOn(AddEditFolderDialogComponent, "open"); const mockDialogService = { open: jest.fn() }; @@ -59,7 +59,7 @@ describe("FoldersV2Component", () => { open.mockClear(); await TestBed.configureTestingModule({ - imports: [FoldersV2Component], + imports: [FoldersComponent], providers: [ { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, @@ -69,7 +69,7 @@ describe("FoldersV2Component", () => { { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, ], }) - .overrideComponent(FoldersV2Component, { + .overrideComponent(FoldersComponent, { remove: { imports: [PopupHeaderComponent, PopupFooterComponent], }, @@ -80,7 +80,7 @@ describe("FoldersV2Component", () => { .overrideProvider(DialogService, { useValue: mockDialogService }) .compileComponents(); - fixture = TestBed.createComponent(FoldersV2Component); + fixture = TestBed.createComponent(FoldersComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders.component.ts similarity index 96% rename from apps/browser/src/vault/popup/settings/folders-v2.component.ts rename to apps/browser/src/vault/popup/settings/folders.component.ts index 20a816e7297..b70c17bd6a5 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.ts @@ -25,7 +25,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./folders-v2.component.html", + templateUrl: "./folders.component.html", imports: [ CommonModule, JslibModule, @@ -39,7 +39,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co AsyncActionsModule, ], }) -export class FoldersV2Component { +export class FoldersComponent { folders$: Observable; NoFoldersIcon = NoFolders; diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts similarity index 97% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts index 0b896547008..01537cbd62e 100644 --- a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts @@ -19,7 +19,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "more-from-bitwarden-page-v2.component.html", + templateUrl: "more-from-bitwarden-page.component.html", imports: [ CommonModule, JslibModule, @@ -30,7 +30,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ItemModule, ], }) -export class MoreFromBitwardenPageV2Component { +export class MoreFromBitwardenPageComponent { protected familySponsorshipAvailable$: Observable; protected isFreeFamilyPolicyEnabled$: Observable; protected hasSingleEnterpriseOrg$: Observable; diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.html rename to apps/browser/src/vault/popup/settings/vault-settings.component.html diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts index 554570de7f9..a948b811fd4 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts @@ -19,7 +19,7 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { VaultSettingsV2Component } from "./vault-settings-v2.component"; +import { VaultSettingsComponent } from "./vault-settings.component"; @Component({ selector: "popup-header", @@ -47,9 +47,9 @@ class MockPopOutComponent { readonly show = input(true); } -describe("VaultSettingsV2Component", () => { - let component: VaultSettingsV2Component; - let fixture: ComponentFixture; +describe("VaultSettingsComponent", () => { + let component: VaultSettingsComponent; + let fixture: ComponentFixture; let router: Router; let mockCipherArchiveService: jest.Mocked; @@ -90,11 +90,11 @@ describe("VaultSettingsV2Component", () => { mockCipherArchiveService.hasArchiveFlagEnabled$ = mockHasArchiveFlagEnabled$.asObservable(); await TestBed.configureTestingModule({ - imports: [VaultSettingsV2Component], + imports: [VaultSettingsComponent], providers: [ provideRouter([ - { path: "archive", component: VaultSettingsV2Component }, - { path: "premium", component: VaultSettingsV2Component }, + { path: "archive", component: VaultSettingsComponent }, + { path: "premium", component: VaultSettingsComponent }, ]), { provide: SyncService, useValue: mock() }, { provide: ToastService, useValue: mock() }, @@ -117,7 +117,7 @@ describe("VaultSettingsV2Component", () => { }, ], }) - .overrideComponent(VaultSettingsV2Component, { + .overrideComponent(VaultSettingsComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], }, @@ -127,7 +127,7 @@ describe("VaultSettingsV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(VaultSettingsV2Component); + fixture = TestBed.createComponent(VaultSettingsComponent); component = fixture.componentInstance; router = TestBed.inject(Router); jest.spyOn(router, "navigate"); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.ts similarity index 97% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.ts index c35345bd8ab..f79cef56155 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.ts @@ -23,7 +23,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "vault-settings-v2.component.html", + templateUrl: "vault-settings.component.html", imports: [ CommonModule, JslibModule, @@ -39,7 +39,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, ], }) -export class VaultSettingsV2Component implements OnInit, OnDestroy { +export class VaultSettingsComponent implements OnInit, OnDestroy { private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); lastSync = "--"; diff --git a/apps/cli/package.json b/apps/cli/package.json index 79653ec970f..6c27267054f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -83,12 +83,12 @@ "multer": "2.0.2", "node-fetch": "2.7.0", "node-forge": "1.3.2", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 2033a2dd064..b5a2b1b8196 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -39,6 +39,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access"; import { AccountServiceImplementation, getUserId, @@ -91,6 +92,8 @@ import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin. import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; +import { SendPasswordService } from "@bitwarden/common/key-management/sends/abstractions/send-password.service"; +import { DefaultSendPasswordService } from "@bitwarden/common/key-management/sends/services/default-send-password.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -306,6 +309,8 @@ export class ServiceContainer { userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; sendApiService: SendApiService; + sendTokenService: SendTokenService; + sendPasswordService: SendPasswordService; devicesApiService: DevicesApiServiceAbstraction; deviceTrustService: DeviceTrustServiceAbstraction; authRequestService: AuthRequestService; @@ -629,6 +634,8 @@ export class ServiceContainer { this.sendService, ); + this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new DefaultCollectionService( @@ -675,6 +682,12 @@ export class ServiceContainer { customUserAgent, ); + this.sendTokenService = new DefaultSendTokenService( + this.globalStateProvider, + this.sdkService, + this.sendPasswordService, + ); + this.keyConnectorService = new KeyConnectorService( this.accountService, this.masterPasswordService, diff --git a/apps/cli/src/tools/send/commands/receive.command.spec.ts b/apps/cli/src/tools/send/commands/receive.command.spec.ts new file mode 100644 index 00000000000..fe982905059 --- /dev/null +++ b/apps/cli/src/tools/send/commands/receive.command.spec.ts @@ -0,0 +1,560 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SendTokenService, SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { KeyService } from "@bitwarden/key-management"; + +import { Response } from "../../../models/response"; + +import { SendReceiveCommand } from "./receive.command"; + +describe("SendReceiveCommand", () => { + let command: SendReceiveCommand; + + const keyService = mock(); + const encryptService = mock(); + const cryptoFunctionService = mock(); + const platformUtilsService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const apiService = mock(); + const sendTokenService = mock(); + const configService = mock(); + + const testUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + const testSendId = "abc123"; + + beforeEach(() => { + jest.clearAllMocks(); + + environmentService.environment$ = of({ + getUrls: () => ({ + api: "https://api.bitwarden.com", + webVault: "https://vault.bitwarden.com", + }), + } as any); + + platformUtilsService.isDev.mockReturnValue(false); + + keyService.makeSendKey.mockResolvedValue({} as any); + + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + command = new SendReceiveCommand( + keyService, + encryptService, + cryptoFunctionService, + platformUtilsService, + environmentService, + sendApiService, + apiService, + sendTokenService, + configService, + ); + }); + + describe("URL parsing", () => { + it("should return error for invalid URL", async () => { + const response = await command.run("not-a-valid-url", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Failed to parse"); + }); + + it("should return error when URL is missing send ID or key", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const response = await command.run("https://send.bitwarden.com/#/send/", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("not a valid Send url"); + }); + }); + + describe("V1 Flow (Feature Flag Off)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + }); + + it("should successfully access unprotected Send", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + }); + + it("should successfully access password-protected Send with --password option", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + "test-password", + expect.any(Uint8Array), + "sha256", + 100000, + ); + }); + + it("should return error for incorrect password in non-interactive mode", async () => { + process.env.BW_NOINTERACTION = "true"; + + const error = new ErrorResponse( + { + statusCode: 401, + message: "Unauthorized", + }, + 401, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Incorrect or missing password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should return 404 for non-existent Send", async () => { + const error = new ErrorResponse( + { + statusCode: 404, + message: "Not found", + }, + 404, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("V2 Flow (Feature Flag On)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + }); + + describe("Unprotected Sends", () => { + it("should successfully access Send with cached token", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(sendTokenService.tryGetSendAccessToken$).toHaveBeenCalledWith(testSendId); + }); + + it("should handle expired token and determine auth type", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + // Mock password auth flow + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + }); + }); + + describe("Password Authentication (V2)", () => { + it("should successfully authenticate with password", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "correct-password" }); + + expect(response.success).toBe(true); + expect(sendTokenService.getSendAccessToken$).toHaveBeenCalledWith( + testSendId, + expect.objectContaining({ + kind: "password", + passwordHashB64: expect.any(String), + }), + ); + }); + + it("should return error for invalid password", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "password_hash_b64_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Invalid password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should work with --passwordenv option", async () => { + process.env.TEST_SEND_PASSWORD = "env-password"; + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { passwordenv: "TEST_SEND_PASSWORD" }); + + expect(response.success).toBe(true); + + delete process.env.TEST_SEND_PASSWORD; + delete process.env.BW_NOINTERACTION; + }); + }); + + describe("Email OTP Authentication (V2)", () => { + it("should return error in non-interactive mode for email OTP", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Email verification required"); + expect(response.message).toContain("interactive mode"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should handle email submission and OTP prompt flow", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValueOnce( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_and_otp_required_otp_sent", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValueOnce(of(mockToken)); + + // We can't easily test the interactive prompts, but we can verify the token service calls + // would be made in the right order + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid email error", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "email_invalid", + }, + } as any), + ); + + // In a real scenario with interactive prompts, this would retry + // For unit tests, we verify the error is recognized + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid OTP error", async () => { + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "otp_invalid", + }, + } as any), + ); + + // Verify OTP validation would be handled + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + }); + + describe("File Downloads (V2)", () => { + it("should successfully download file Send with V2 API", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockSendResponse = { + id: testSendId, + type: SendType.File, + file: { + id: "file-123", + fileName: "test.pdf", + size: 1024, + }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue(mockSendResponse as any); + sendApiService.getSendFileDownloadDataV2.mockResolvedValue({ + url: "https://example.com/download", + } as any); + + encryptService.decryptFileData.mockResolvedValue(new ArrayBuffer(1024) as any); + jest.spyOn(command as any, "saveAttachmentToFile").mockResolvedValue(Response.success()); + + await command.run(testUrl, { output: "./test.pdf" }); + + expect(sendApiService.getSendFileDownloadDataV2).toHaveBeenCalledWith( + expect.any(Object), + mockToken, + "https://api.bitwarden.com", + ); + }); + }); + + describe("Invalid Send ID", () => { + it("should return 404 for invalid Send ID", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "send_id_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("Text Send Output", () => { + it("should output text to stdout for text Sends", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const secretText = "This is a secret message"; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + process.stdout.write(secretText); + return Response.success(); + }); + + const stdoutSpy = jest.spyOn(process.stdout, "write").mockImplementation(() => true); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(stdoutSpy).toHaveBeenCalledWith(secretText); + + stdoutSpy.mockRestore(); + }); + + it("should return JSON object when --obj flag is used", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockDecryptedView = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + const sendAccessResponse = new SendAccessResponse(mockDecryptedView as any); + const res = new Response(); + res.success = true; + res.data = sendAccessResponse as any; + return res; + }); + + const response = await command.run(testUrl, { obj: true }); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + expect(response.data.constructor.name).toBe("SendAccessResponse"); + }); + }); + }); + + describe("API URL Resolution", () => { + it("should resolve send.bitwarden.com to api.bitwarden.com", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const sendUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(sendUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(sendUrl)); + expect(apiUrl).toBe("https://api.bitwarden.com"); + }); + + it("should handle custom domain URLs", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const customUrl = "https://custom.example.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(customUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(customUrl)); + expect(apiUrl).toBe("https://custom.example.com/api"); + }); + }); + + describe("Feature Flag Routing", () => { + it("should route to V1 flow when feature flag is off", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + sendApiService.postSendAccess.mockResolvedValue({} as any); + const v1Spy = jest.spyOn(command as any, "attemptV1Access"); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v1Spy).toHaveBeenCalled(); + }); + + it("should route to V2 flow when feature flag is on", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const v2Spy = jest.spyOn(command as any, "attemptV2Access"); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v2Spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index 5cbf458c87f..9496855a7a5 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -5,9 +5,25 @@ import * as inquirer from "inquirer"; import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { + SendTokenService, + SendAccessToken, + emailRequired, + emailAndOtpRequired, + otpInvalid, + passwordHashB64Required, + passwordHashB64Invalid, + sendIdInvalid, + SendHashedPasswordB64, + SendOtp, + GetSendAccessTokenError, + SendAccessDomainCredentials, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -17,6 +33,7 @@ import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-acce import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -38,6 +55,8 @@ export class SendReceiveCommand extends DownloadCommand { private environmentService: EnvironmentService, private sendApiService: SendApiService, apiService: ApiService, + private sendTokenService: SendTokenService, + private configService: ConfigService, ) { super(encryptService, apiService); } @@ -62,58 +81,13 @@ export class SendReceiveCommand extends DownloadCommand { } const keyArray = Utils.fromUrlB64ToArray(key); - this.sendAccessRequest = new SendAccessRequest(); - let password = options.password; - if (password == null || password === "") { - if (options.passwordfile) { - password = await NodeUtils.readFirstLine(options.passwordfile); - } else if (options.passwordenv && process.env[options.passwordenv]) { - password = process.env[options.passwordenv]; - } - } + const sendEmailOtpEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); - if (password != null && password !== "") { - this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); - } - - const response = await this.sendRequest(apiUrl, id, keyArray); - - if (response instanceof Response) { - // Error scenario - return response; - } - - if (options.obj != null) { - return Response.success(new SendAccessResponse(response)); - } - - switch (response.type) { - case SendType.Text: - // Write to stdout and response success so we get the text string only to stdout - process.stdout.write(response?.text?.text); - return Response.success(); - case SendType.File: { - const downloadData = await this.sendApiService.getSendFileDownloadData( - response, - this.sendAccessRequest, - apiUrl, - ); - - const decryptBufferFn = async (resp: globalThis.Response) => { - const encBuf = await EncArrayBuffer.fromResponse(resp); - return this.encryptService.decryptFileData(encBuf, this.decKey); - }; - - return await this.saveAttachmentToFile( - downloadData.url, - response?.file?.fileName, - decryptBufferFn, - options.output, - ); - } - default: - return Response.success(new SendAccessResponse(response)); + if (sendEmailOtpEnabled) { + return await this.attemptV2Access(apiUrl, id, keyArray, options); + } else { + return await this.attemptV1Access(apiUrl, id, keyArray, options); } } @@ -146,6 +120,350 @@ export class SendReceiveCommand extends DownloadCommand { return Utils.fromBufferToB64(passwordHash); } + private async attemptV1Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise { + this.sendAccessRequest = new SendAccessRequest(); + + let password = options.password; + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if (password != null && password !== "") { + this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); + } + + const response = await this.sendRequest(apiUrl, id, keyArray); + + if (response instanceof Response) { + return response; + } + + if (options.obj != null) { + return Response.success(new SendAccessResponse(response)); + } + + switch (response.type) { + case SendType.Text: + process.stdout.write(response?.text?.text); + return Response.success(); + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadData( + response, + this.sendAccessRequest, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + response?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + default: + return Response.success(new SendAccessResponse(response)); + } + } + + private async attemptV2Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise { + let authType: AuthType = AuthType.None; + + const currentResponse = await this.getTokenWithRetry(id); + + if (currentResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(currentResponse, keyArray, apiUrl, options); + } + + if (currentResponse.kind === "expected_server") { + const error = currentResponse.error; + + if (emailRequired(error)) { + authType = AuthType.Email; + } else if (passwordHashB64Required(error)) { + authType = AuthType.Password; + } else if (sendIdInvalid(error)) { + return Response.notFound(); + } + } else { + return this.handleError(currentResponse); + } + + // Handle authentication based on type + if (authType === AuthType.Email) { + if (!this.canInteract) { + return Response.badRequest("Email verification required. Run in interactive mode."); + } + return await this.handleEmailOtpAuth(id, keyArray, apiUrl, options); + } else if (authType === AuthType.Password) { + return await this.handlePasswordAuth(id, keyArray, apiUrl, options); + } + + // The auth layer will immediately return a token for Sends with AuthType.None + // If this code is reached, something has gone wrong + if (authType === AuthType.None) { + return Response.error("Could not determine authentication requirements"); + } + + return Response.error("Authentication failed"); + } + + private async getTokenWithRetry( + sendId: string, + credentials?: SendAccessDomainCredentials, + ): Promise { + let expiredAttempts = 0; + + while (expiredAttempts < 3) { + const response = credentials + ? await firstValueFrom(this.sendTokenService.getSendAccessToken$(sendId, credentials)) + : await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(sendId)); + + if (response instanceof SendAccessToken) { + return response; + } + + if (response.kind === "expired") { + expiredAttempts++; + continue; + } + + // Not expired, return the response for caller to handle + return response; + } + + // After 3 expired attempts, return an error response + return { + kind: "unknown", + error: "Send access token has expired and could not be refreshed", + }; + } + + private handleError(error: GetSendAccessTokenError): Response { + if (error.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(error.error)); + } + + return Response.error("Error: " + JSON.stringify(error.error)); + } + + private async promptForOtp(sendId: string, email: string): Promise { + const otpAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "otp", + message: "Enter the verification code sent to your email:", + }); + return otpAnswer.otp; + } + + private async promptForEmail(): Promise { + const emailAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "email", + message: "Enter your email address:", + validate: (input: string) => { + if (!input || !input.includes("@")) { + return "Please enter a valid email address"; + } + return true; + }, + }); + return emailAnswer.email; + } + + private async handleEmailOtpAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + const email = await this.promptForEmail(); + + const emailResponse = await this.getTokenWithRetry(sendId, { + kind: "email", + email: email, + }); + + if (emailResponse instanceof SendAccessToken) { + /* + At this point emailResponse should only be expected to be a GetSendAccessTokenError type, + but TS must have a logical branch in case it is a SendAccessToken type. If a valid token is + returned by the method above, something has gone wrong. + */ + + return Response.error("Unexpected server response"); + } + + if (emailResponse.kind === "expected_server") { + const error = emailResponse.error; + + if (emailAndOtpRequired(error)) { + const promptResponse = await this.promptForOtp(sendId, email); + + // Use retry helper for expired token handling + const otpResponse = await this.getTokenWithRetry(sendId, { + kind: "email_otp", + email: email, + otp: promptResponse, + }); + + if (otpResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(otpResponse, keyArray, apiUrl, options); + } + + if (otpResponse.kind === "expected_server") { + const error = otpResponse.error; + + if (otpInvalid(error)) { + return Response.badRequest("Invalid email or verification code"); + } + + /* + If the following evaluates to true, it means that the email address provided was not + configured to be used for email OTP for this Send. + + To avoid leaking information that would allow email enumeration, instead return an + error indicating that some component of the email OTP challenge was invalid. + */ + if (emailAndOtpRequired(error)) { + return Response.badRequest("Invalid email or verification code"); + } + } + return this.handleError(otpResponse); + } + } + return this.handleError(emailResponse); + } + + private async handlePasswordAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + let password = options.password; + + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if ((password == null || password === "") && this.canInteract) { + const answer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "password", + name: "password", + message: "Send password:", + }); + password = answer.password; + } + + if (!password) { + return Response.badRequest("Password required"); + } + + const passwordHashB64 = await this.getUnlockedPassword(password, keyArray); + + // Use retry helper for expired token handling + const response = await this.getTokenWithRetry(sendId, { + kind: "password", + passwordHashB64: passwordHashB64 as SendHashedPasswordB64, + }); + + if (response instanceof SendAccessToken) { + return await this.accessSendWithToken(response, keyArray, apiUrl, options); + } + + if (response.kind === "expected_server") { + const error = response.error; + + if (passwordHashB64Invalid(error)) { + return Response.badRequest("Invalid password"); + } + } else if (response.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(response.error)); + } else if (response.kind === "unknown") { + return Response.error("Error: " + response.error); + } + + return Response.error("Authentication failed"); + } + + private async accessSendWithToken( + accessToken: SendAccessToken, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + try { + const sendResponse = await this.sendApiService.postSendAccessV2(accessToken, apiUrl); + + const sendAccess = new SendAccess(sendResponse); + this.decKey = await this.keyService.makeSendKey(keyArray); + const decryptedView = await sendAccess.decrypt(this.decKey); + + if (options.obj != null) { + return Response.success(new SendAccessResponse(decryptedView)); + } + + switch (decryptedView.type) { + case SendType.Text: + process.stdout.write(decryptedView?.text?.text); + return Response.success(); + + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadDataV2( + decryptedView, + accessToken, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + decryptedView?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + + default: + return Response.success(new SendAccessResponse(decryptedView)); + } + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 404) { + return Response.notFound(); + } + } + return Response.error(e); + } + } + private async sendRequest( url: string, id: string, diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index a84b6c15ead..e40cea4daa9 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -133,6 +133,8 @@ export class SendProgram extends BaseProgram { this.serviceContainer.environmentService, this.serviceContainer.sendApiService, this.serviceContainer.apiService, + this.serviceContainer.sendTokenService, + this.serviceContainer.configService, ); const response = await cmd.run(url, options); this.processResponse(response); diff --git a/apps/cli/src/vault/archive.command.ts b/apps/cli/src/vault/archive.command.ts index 5ced2282c6d..0f634f78fb3 100644 --- a/apps/cli/src/vault/archive.command.ts +++ b/apps/cli/src/vault/archive.command.ts @@ -99,9 +99,6 @@ export class ArchiveCommand { errorMessage: "Item is in the trash, the item must be restored before archiving.", }; } - case cipher.organizationId != null: { - return { canArchive: false, errorMessage: "Cannot archive items in an organization." }; - } default: return { canArchive: true }; } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 01eb8c728e5..fdd5012f5ee 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -273,7 +273,7 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = false; break; case "lockVault": - await this.lockService.lock(message.userId); + await this.lockService.lock(message.userId ?? this.activeUserId); break; case "lockAllVaults": { await this.lockService.lockAll(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 550bbcad81e..85742db94ab 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4486,6 +4486,9 @@ "sessionTimeoutSettingsAction": { "message": "Timeout action" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "sessionTimeoutHeader": { "message": "Session timeout" }, diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index b2008d57bcd..2872154aa44 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -55,6 +55,11 @@ export class WindowMain { // Perform a hard reload of the render process by crashing it. This is suboptimal but ensures that all memory gets // cleared, as the process itself will be completely garbage collected. ipcMain.on("reload-process", async () => { + if (isDev()) { + this.logService.info("Process reload requested, but skipping in development mode"); + return; + } + this.logService.info("Reloading render process"); // User might have changed theme, ensure the window is updated. this.win.setBackgroundColor(await this.getBackgroundColor()); diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index 9377ac567ec..70c28c66353 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -163,8 +163,14 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return "Snap"; } else if (ipc.platform.isFlatpak) { return "Flatpak"; + } else if (this.getDevice() === DeviceType.WindowsDesktop) { + return "WindowsUnknown"; + } else if (this.getDevice() === DeviceType.MacOsDesktop) { + return "MacOSUnknown"; + } else if (this.getDevice() === DeviceType.LinuxDesktop) { + return "LinuxUnknown"; } else { - return "Unknown"; + return "DesktopUnknown"; } } } diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 02c9873c295..133a9777fab 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -263,15 +263,12 @@ export class ItemFooterComponent implements OnInit, OnChanges { this.userCanArchive = userCanArchive; this.showArchiveButton = - cipherCanBeArchived && - userCanArchive && - (this.action === "view" || this.action === "edit") && - !this.cipher.isArchived; + cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; // A user should always be able to unarchive an archived item this.showUnarchiveButton = hasArchiveFlagEnabled && - (this.action === "view" || this.action === "edit") && + this.action === "view" && this.cipher.isArchived && !this.cipher.isDeleted; } diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html new file mode 100644 index 00000000000..2fbcc8afd86 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html @@ -0,0 +1,22 @@ + +
+
+
+ +
+
+
+

+ {{ "bulkReinviteProgressTitle" | i18n: progressCount() : allCount }} +

+ + {{ "bulkReinviteProgressSubtitle" | i18n }} + +
+
+
diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts new file mode 100644 index 00000000000..66582fb4434 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts @@ -0,0 +1,46 @@ +import { DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + Inject, + Signal, +} from "@angular/core"; + +import { DIALOG_DATA, DialogService } from "@bitwarden/components"; + +export interface BulkProgressDialogParams { + progress: Signal; + allCount: number; +} + +@Component({ + templateUrl: "bulk-progress-dialog.component.html", + selector: "member-bulk-progress-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class BulkProgressDialogComponent { + protected allCount: string; + protected readonly progressCount: Signal; + protected readonly progressPercentage: Signal; + private readonly progressEffect = effect(() => { + if (this.progressPercentage() >= 100) { + this.dialogRef.close(); + } + }); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) data: BulkProgressDialogParams, + ) { + this.progressCount = computed(() => data.progress().toLocaleString()); + this.allCount = data.allCount.toLocaleString(); + this.progressPercentage = computed(() => (data.progress() / data.allCount) * 100); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkProgressDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html new file mode 100644 index 00000000000..0f216be6e5f --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html @@ -0,0 +1,70 @@ + + @let failCount = dataSource().data.length; +
+ @if (failCount > 1) { + {{ "bulkReinviteFailuresTitle" | i18n: failCount }} + } @else { + {{ "bulkReinviteFailureTitle" | i18n }} + } +
+ +
+ {{ "bulkReinviteFailureDescription" | i18n: failCount : totalCount }} + + + {{ "contactSupportShort" | i18n | lowercase }} + + + +
+ + + + {{ "name" | i18n }} + + + + + @let rows = $any(rows$ | async); + @for (u of rows; track u.id) { + + +
+ +
+
+ +
+ @if (u.name) { +
+ {{ u.email }} +
+ } +
+
+ + + } +
+
+
+
+ + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts new file mode 100644 index 00000000000..5cb11708fd0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts @@ -0,0 +1,62 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { ChangeDetectionStrategy, Component, Inject, signal, WritableSignal } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { DialogService } from "@bitwarden/components"; +import { MembersTableDataSource } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; + +import { OrganizationUserView } from "../../../core"; +import { + BulkActionResult, + MemberActionsService, +} from "../../services/member-actions/member-actions.service"; + +export interface BulkReinviteFailureDialogParams { + result: BulkActionResult; + users: OrganizationUserView[]; + organization: Organization; +} + +@Component({ + templateUrl: "bulk-reinvite-failure-dialog.component.html", + selector: "member-bulk-reinvite-failure-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class BulkReinviteFailureDialogComponent { + private organization: Organization; + protected totalCount: string; + protected readonly dataSource: WritableSignal; + + constructor( + public dialogRef: DialogRef, + private memberActionsService: MemberActionsService, + @Inject(DIALOG_DATA) data: BulkReinviteFailureDialogParams, + environmentService: EnvironmentService, + ) { + this.organization = data.organization; + this.totalCount = (data.users.length ?? 0).toLocaleString(); + this.dataSource = signal(new MembersTableDataSource(environmentService)); + this.dataSource().data = data.result.failed.map((failedUser) => { + const user = data.users.find((u) => u.id === failedUser.id); + if (user == null) { + throw new Error("Member not found"); + } + return user; + }); + } + + async resendInvitations() { + await this.memberActionsService.bulkReinvite(this.organization, this.dataSource().data); + this.dialogRef.close(); + } + + async cancel() { + this.dialogRef.close(); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkReinviteFailureDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 197c5d3efb5..dae9bafbcfe 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -444,10 +444,7 @@ export class MembersComponent extends BaseMembersComponent } try { - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers); if (!result.successful) { throw new Error(); @@ -472,7 +469,10 @@ export class MembersComponent extends BaseMembersComponent } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 0f074d4481d..75ef503366b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -113,25 +113,24 @@ {{ "policies" | i18n }} @if (showUserManagementControls()) { - -
- - -
- +
+ + +
} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts index 246c3d8a1c0..1cd90989b12 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -519,7 +519,7 @@ describe("vNextMembersComponent", () => { await component.bulkReinvite(mockOrg); - expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]); + expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser]); expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled(); }); diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 36c207219a0..6139c5f07a5 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,6 +33,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; @@ -101,6 +103,7 @@ export class vNextMembersComponent { private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); + private configService = inject(ConfigService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); @@ -145,6 +148,10 @@ export class vNextMembersComponent { () => this.organization()?.canManageUsers ?? false, ); + protected readonly bulkReinviteUIEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI), + ); + protected billingMetadata$: Observable; protected resetPasswordPolicyEnabled$: Observable; @@ -399,7 +406,7 @@ export class vNextMembersComponent { // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; - if (this.dataSource().isIncreasedBulkLimitEnabled()) { + if (this.dataSource().isIncreasedBulkLimitEnabled() && !this.bulkReinviteUIEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( allInvitedUsers, CloudBulkReinviteLimit, @@ -417,10 +424,7 @@ export class vNextMembersComponent { return; } - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers); if (!result.successful) { this.validationService.showError(result.failed); @@ -431,7 +435,8 @@ export class vNextMembersComponent { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; - if (selectedCount > CloudBulkReinviteLimit) { + // Only show limited toast if feature flag is disabled and limit was applied + if (!this.bulkReinviteUIEnabled() && selectedCount > CloudBulkReinviteLimit) { const excludedCount = selectedCount - CloudBulkReinviteLimit; this.toastService.showToast({ variant: "success", @@ -445,7 +450,10 @@ export class vNextMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { @@ -457,6 +465,8 @@ export class vNextMembersComponent { this.i18nService.t("bulkReinviteMessage"), ); } + + this.dataSource().uncheckAllUsers(); } async bulkConfirm(organization: Organization) { diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 9fd477b1e29..54e2d1b6373 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -3,7 +3,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; -import { ScrollLayoutDirective } from "@bitwarden/components"; +import { IconModule, ScrollLayoutDirective } from "@bitwarden/components"; import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; @@ -13,6 +13,8 @@ import { SharedOrganizationModule } from "../shared"; import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; +import { BulkProgressDialogComponent } from "./components/bulk/bulk-progress-dialog.component"; +import { BulkReinviteFailureDialogComponent } from "./components/bulk/bulk-reinvite-failure-dialog.component"; import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; @@ -39,6 +41,7 @@ import { PasswordStrengthV2Component, ScrollLayoutDirective, OrganizationFreeTrialWarningComponent, + IconModule, ], declarations: [ BulkConfirmDialogComponent, @@ -46,6 +49,8 @@ import { BulkRemoveDialogComponent, BulkRestoreRevokeComponent, BulkStatusComponent, + BulkProgressDialogComponent, + BulkReinviteFailureDialogComponent, MembersComponent, vNextMembersComponent, BulkDeleteDialogComponent, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 5924c2f7814..688c7ed77ce 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -18,6 +18,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -25,6 +26,7 @@ import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service"; import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service"; @@ -34,6 +36,7 @@ describe("MemberActionsService", () => { let organizationUserService: MockProxy; let configService: MockProxy; let organizationMetadataService: MockProxy; + let memberDialogManager: MockProxy; const organizationId = newGuid() as OrganizationId; const userIdToManage = newGuid(); @@ -46,6 +49,7 @@ describe("MemberActionsService", () => { organizationUserService = mock(); configService = mock(); organizationMetadataService = mock(); + memberDialogManager = mock(); mockOrganization = { id: organizationId, @@ -82,6 +86,8 @@ describe("MemberActionsService", () => { useValue: mock(), }, { provide: UserNamePipe, useValue: mock() }, + { provide: MemberDialogManagerService, useValue: memberDialogManager }, + { provide: I18nService, useValue: mock() }, ], }); @@ -318,8 +324,13 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse = new ListResponse( { data: userIdsBatch.map((id) => ({ @@ -333,10 +344,10 @@ describe("MemberActionsService", () => { organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.successful).toHaveLength(REQUESTS_PER_BATCH); expect(result.failed).toHaveLength(0); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( @@ -348,6 +359,7 @@ describe("MemberActionsService", () => { it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { const totalUsers = REQUESTS_PER_BATCH + 100; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -375,10 +387,10 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful).toHaveLength(totalUsers); expect(result.failed).toHaveLength(0); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( @@ -396,6 +408,7 @@ describe("MemberActionsService", () => { it("should aggregate results across multiple successful batches", async () => { const totalUsers = REQUESTS_PER_BATCH + 50; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -423,18 +436,19 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.successful).toHaveLength(totalUsers); + expect(result.successful!.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful!.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); expect(result.failed).toHaveLength(0); }); it("should handle mixed individual errors across multiple batches", async () => { const totalUsers = REQUESTS_PER_BATCH + 4; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -464,7 +478,7 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values @@ -474,7 +488,7 @@ describe("MemberActionsService", () => { const expectedSuccesses = totalUsers - expectedTotalFailures; expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.successful).toHaveLength(expectedSuccesses); expect(result.failed).toHaveLength(expectedTotalFailures); expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); @@ -484,13 +498,14 @@ describe("MemberActionsService", () => { it("should aggregate all failures when all batches fail", async () => { const totalUsers = REQUESTS_PER_BATCH + 100; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const errorMessage = "All batches failed"; organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( new Error(errorMessage), ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeUndefined(); expect(result.failed).toHaveLength(totalUsers); @@ -501,6 +516,7 @@ describe("MemberActionsService", () => { it("should handle empty data in batch response", async () => { const totalUsers = REQUESTS_PER_BATCH + 50; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -525,16 +541,17 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.successful).toHaveLength(REQUESTS_PER_BATCH); expect(result.failed).toHaveLength(0); }); it("should process batches sequentially in order", async () => { const totalUsers = REQUESTS_PER_BATCH * 2; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const callOrder: number[] = []; organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( @@ -555,11 +572,161 @@ describe("MemberActionsService", () => { }, ); - await service.bulkReinvite(mockOrganization, userIdsBatch); + await service.bulkReinvite(mockOrganization, users); expect(callOrder).toEqual([1, 2]); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); + + describe("with BulkReinviteUI feature flag enabled", () => { + let mockDialogService: MockProxy; + let mockI18nService: MockProxy; + + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + mockDialogService = TestBed.inject(DialogService) as MockProxy; + mockI18nService = TestBed.inject(I18nService) as MockProxy; + mockI18nService.t.mockImplementation((key: string) => key); + }); + + it("should open progress dialog when user count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + const mockDialogRef = { closed: of(undefined) }; + memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled(); + expect(memberDialogManager.openBulkProgressDialog).toHaveBeenCalledWith( + expect.anything(), + totalUsers, + ); + }); + + it("should not open progress dialog when user count is or below REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled(); + expect(memberDialogManager.openBulkProgressDialog).not.toHaveBeenCalled(); + }); + + it("should open failure dialog when there are failures", async () => { + const totalUsers = 10; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: "error", + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + const result = await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).toHaveBeenCalledWith( + mockOrganization, + users, + result, + ); + expect(result.failed.length).toBeGreaterThan(0); + }); + + it("should process batches when exceeding REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockDialogRef = { closed: of(undefined) }; + memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + await service.bulkReinvite(mockOrganization, users); + + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); + }); }); describe("allowResetPassword", () => { diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3b0db124a6b..e5f8c0c6673 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -1,5 +1,5 @@ -import { inject, Injectable, signal } from "@angular/core"; -import { lastValueFrom, firstValueFrom, switchMap } from "rxjs"; +import { inject, Injectable, signal, WritableSignal } from "@angular/core"; +import { lastValueFrom, firstValueFrom, switchMap, take } from "rxjs"; import { OrganizationUserApiService, @@ -23,11 +23,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { UserId } from "@bitwarden/user-core"; import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { UserConfirmComponent } from "../../../manage/user-confirm.component"; +import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service"; export const REQUESTS_PER_BATCH = 500; @@ -36,9 +36,13 @@ export interface MemberActionResult { error?: string; } -export interface BulkActionResult { - successful?: ListResponse; - failed: { id: string; error: string }[]; +export class BulkActionResult { + constructor() { + this.failed = []; + } + + successful?: OrganizationUserBulkResponse[]; + failed: { id: string; error: string }[] = []; } @Injectable() @@ -53,17 +57,28 @@ export class MemberActionsService { private logService = inject(LogService); private orgManagementPrefs = inject(OrganizationManagementPreferencesService); private userNamePipe = inject(UserNamePipe); + private memberDialogManager = inject(MemberDialogManagerService); readonly isProcessing = signal(false); - private startProcessing(): void { + private startProcessing(length?: number): void { this.isProcessing.set(true); + if (length != null && length > REQUESTS_PER_BATCH) { + this.memberDialogManager + .openBulkProgressDialog(this.progressCount, length) + .closed.pipe(take(1)) + .subscribe(() => { + this.progressCount.set(0); + }); + } } private endProcessing(): void { this.isProcessing.set(false); } + private readonly progressCount: WritableSignal = signal(0); + async inviteUser( organization: Organization, email: string, @@ -186,19 +201,42 @@ export class MemberActionsService { } } - async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { - this.startProcessing(); + async bulkReinvite( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + let result = new BulkActionResult(); + const bulkReinviteUIEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI), + ); + + if (bulkReinviteUIEnabled) { + this.startProcessing(users.length); + } else { + this.startProcessing(); + } + try { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); + result = await this.processBatchedOperation(users, REQUESTS_PER_BATCH, (userBatch) => { + const userIds = userBatch.map((u) => u.id); + return this.organizationUserApiService.postManyOrganizationUserReinvite( + organization.id, + userIds, + ); + }); + + if (bulkReinviteUIEnabled && result.failed.length > 0) { + this.memberDialogManager.openBulkReinviteFailureDialog(organization, users, result); + } } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; + result.failed = users.map((user) => ({ + id: user.id, + error: (error as Error).message ?? String(error), + })); } finally { this.endProcessing(); } + return result; } allowResetPassword( @@ -235,21 +273,23 @@ export class MemberActionsService { /** * Processes user IDs in sequential batches and aggregates results. - * @param userIds - Array of user IDs to process + * @param users - Array of users to process * @param batchSize - Number of IDs to process per batch - * @param processBatch - Async function that processes a single batch and returns the result + * @param processBatch - Async function that processes a single batch from the provided param `users` and returns the result. * @returns Aggregated bulk action result */ private async processBatchedOperation( - userIds: UserId[], + users: OrganizationUserView[], batchSize: number, - processBatch: (batch: string[]) => Promise>, + processBatch: ( + batch: OrganizationUserView[], + ) => Promise>, ): Promise { const allSuccessful: OrganizationUserBulkResponse[] = []; const allFailed: { id: string; error: string }[] = []; - for (let i = 0; i < userIds.length; i += batchSize) { - const batch = userIds.slice(i, i + batchSize); + for (let i = 0; i < users.length; i += batchSize) { + const batch = users.slice(i, i + batchSize); try { const result = await processBatch(batch); @@ -265,18 +305,18 @@ export class MemberActionsService { } } catch (error) { allFailed.push( - ...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + ...batch.map((user) => ({ + id: user.id, + error: (error as Error).message ?? String(error), + })), ); } + + this.progressCount.update((value) => value + batch.length); } - const successful = - allSuccessful.length > 0 - ? new ListResponse(allSuccessful, OrganizationUserBulkResponse) - : undefined; - return { - successful, + successful: allSuccessful.length > 0 ? allSuccessful : undefined, failed: allFailed, }; } diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts index c6ef536af2b..18106031fd0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { Injectable, WritableSignal } from "@angular/core"; import { firstValueFrom, lastValueFrom } from "rxjs"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -7,7 +7,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { CenterPositionStrategy, DialogService, ToastService } from "@bitwarden/components"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { openEntityEventsDialog } from "../../../manage/entity-events.component"; @@ -18,6 +18,8 @@ import { import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component"; import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component"; +import { BulkProgressDialogComponent } from "../../components/bulk/bulk-progress-dialog.component"; +import { BulkReinviteFailureDialogComponent } from "../../components/bulk/bulk-reinvite-failure-dialog.component"; import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "../../components/bulk/bulk-status.component"; @@ -27,6 +29,7 @@ import { openUserAddEditDialog, } from "../../components/member-dialog"; import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service"; +import { BulkActionResult } from "../member-actions/member-actions.service"; @Injectable() export class MemberDialogManagerService { @@ -319,4 +322,33 @@ export class MemberDialogManagerService { type: "warning", }); } + + openBulkProgressDialog(progress: WritableSignal, allCount: number) { + return this.dialogService.open(BulkProgressDialogComponent, { + disableClose: true, + positionStrategy: new CenterPositionStrategy(), + data: { + progress, + allCount, + }, + }); + } + + openBulkReinviteFailureDialog( + organization: Organization, + users: OrganizationUserView[], + result: BulkActionResult, + ) { + return this.dialogService.open( + BulkReinviteFailureDialogComponent, + { + positionStrategy: new CenterPositionStrategy(), + data: { + organization, + users, + result, + }, + }, + ); + } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index d13a2097628..0dd0b67c189 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core"; +import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs"; @@ -14,7 +14,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { DialogService } from "@bitwarden/components"; +import { DialogRef, DialogService } from "@bitwarden/components"; import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -37,7 +37,8 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token"; ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PoliciesComponent { +export class PoliciesComponent implements OnDestroy { + private myDialogRef?: DialogRef; private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); protected organizationId$: Observable = this.route.params.pipe( @@ -98,6 +99,10 @@ export class PoliciesComponent { this.handleLaunchEvent(); } + ngOnDestroy() { + this.myDialogRef?.close(); + } + // Handle policies component launch from Event message private handleLaunchEvent() { combineLatest([ @@ -131,7 +136,7 @@ export class PoliciesComponent { edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) { const dialogComponent: PolicyDialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent; - dialogComponent.open(this.dialogService, { + this.myDialogRef = dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: organizationId, diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index ba118ea6663..144396d6772 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -43,7 +43,7 @@ > } } - + {{ "name" | i18n }} diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 5f047316a29..5a187427b5e 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -45,7 +45,7 @@ > } } - + {{ "name" | i18n }} diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index c0b734f17cc..a2330025c92 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -1,7 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; +import { LogoutService } from "@bitwarden/auth/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -12,6 +13,8 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { SignedPublicKey, @@ -21,7 +24,6 @@ import { WrappedPrivateKey, WrappedSigningKey, } from "@bitwarden/common/key-management/types"; -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -276,7 +278,7 @@ describe("KeyRotationService", () => { let mockSyncService: MockProxy; let mockWebauthnLoginAdminService: MockProxy; let mockLogService: MockProxy; - let mockVaultTimeoutService: MockProxy; + let mockLogoutService: MockProxy; let mockDialogService: MockProxy; let mockToastService: MockProxy; let mockI18nService: MockProxy; @@ -284,6 +286,7 @@ describe("KeyRotationService", () => { let mockKdfConfigService: MockProxy; let mockSdkClientFactory: MockProxy; let mockSecurityStateService: MockProxy; + let mockMasterPasswordService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -293,6 +296,8 @@ describe("KeyRotationService", () => { }), }; + const mockUserSalt = "usersalt"; + const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; const mockMakeKeysForUserCryptoV2 = jest.fn(); @@ -337,7 +342,7 @@ describe("KeyRotationService", () => { mockSyncService = mock(); mockWebauthnLoginAdminService = mock(); mockLogService = mock(); - mockVaultTimeoutService = mock(); + mockLogoutService = mock(); mockToastService = mock(); mockI18nService = mock(); mockDialogService = mock(); @@ -354,6 +359,7 @@ describe("KeyRotationService", () => { }, } as BitwardenClient); mockSecurityStateService = mock(); + mockMasterPasswordService = mock(); keyRotationService = new TestUserKeyRotationService( mockApiService, @@ -368,7 +374,7 @@ describe("KeyRotationService", () => { mockSyncService, mockWebauthnLoginAdminService, mockLogService, - mockVaultTimeoutService, + mockLogoutService, mockToastService, mockI18nService, mockDialogService, @@ -377,6 +383,7 @@ describe("KeyRotationService", () => { mockKdfConfigService, mockSdkClientFactory, mockSecurityStateService, + mockMasterPasswordService, ); }); @@ -391,10 +398,10 @@ describe("KeyRotationService", () => { value: Promise.resolve(), configurable: true, }); + mockMasterPasswordService.saltForUser$.mockReturnValue(of(mockUserSalt as MasterPasswordSalt)); }); describe("rotateUserKeyMasterPasswordAndEncryptedData", () => { - let privateKey: BehaviorSubject; let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; beforeEach(() => { @@ -420,10 +427,6 @@ describe("KeyRotationService", () => { mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]); - // Mock private key - privateKey = new BehaviorSubject("mockPrivateKey" as any); - mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey); - keyPair = new BehaviorSubject({ privateKey: "mockPrivateKey", publicKey: "mockPublicKey", @@ -543,7 +546,7 @@ describe("KeyRotationService", () => { expect(spy).toHaveBeenCalledWith( mockUser.id, expect.any(PBKDF2KdfConfig), - mockUser.email, + mockUserSalt, expect.objectContaining({ version: 1 }), true, ); @@ -683,7 +686,7 @@ describe("KeyRotationService", () => { }, signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, - }, + } as V2CryptographicStateParameters, ); expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled(); expect(result).toEqual({ @@ -810,7 +813,7 @@ describe("KeyRotationService", () => { masterPasswordHash: "omitted", otp: undefined, authRequestAccessCode: undefined, - }, + } as OrganizationUserResetPasswordWithIdRequest, ]); mockKeyService.makeMasterKey.mockResolvedValue( new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, @@ -1122,7 +1125,7 @@ describe("KeyRotationService", () => { const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); expect(cryptographicState).toEqual({ masterKeyKdfConfig: new PBKDF2KdfConfig(100000), - masterKeySalt: "mockemail", // the email is lowercased to become the salt + masterKeySalt: mockUserSalt, cryptographicStateParameters: { version: 1, userKey: TEST_VECTOR_USER_KEY_V1, @@ -1138,7 +1141,7 @@ describe("KeyRotationService", () => { const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); expect(cryptographicState).toEqual({ masterKeyKdfConfig: new PBKDF2KdfConfig(100000), - masterKeySalt: "mockemail", // the email is lowercased to become the salt + masterKeySalt: mockUserSalt, cryptographicStateParameters: { version: 2, userKey: TEST_VECTOR_USER_KEY_V2, diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index b9bd23b12de..68253a4a35d 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -8,6 +8,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { SignedPublicKey, @@ -99,6 +100,7 @@ export class UserKeyRotationService { private kdfConfigService: KdfConfigService, private sdkClientFactory: SdkClientFactory, private securityStateService: SecurityStateService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} /** @@ -146,7 +148,7 @@ export class UserKeyRotationService { const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged( user.id, masterKeyKdfConfig, - user.email, + masterKeySalt, currentCryptographicStateParameters, upgradeToV2FeatureFlagEnabled, ); @@ -300,7 +302,7 @@ export class UserKeyRotationService { protected async upgradeV1UserToV2UserAccountKeys( userId: UserId, kdfConfig: KdfConfig, - email: string, + masterKeySalt: string, cryptographicStateParameters: V1CryptographicStateParameters, ): Promise { // Initialize an SDK with the current cryptographic state @@ -308,7 +310,7 @@ export class UserKeyRotationService { await sdk.crypto().initialize_user_crypto({ userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), - email: email, + email: masterKeySalt, accountCryptographicState: { V1: { private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, @@ -328,7 +330,7 @@ export class UserKeyRotationService { protected async rotateV2UserAccountKeys( userId: UserId, kdfConfig: KdfConfig, - email: string, + masterKeySalt: string, cryptographicStateParameters: V2CryptographicStateParameters, ): Promise { // Initialize an SDK with the current cryptographic state @@ -336,7 +338,7 @@ export class UserKeyRotationService { await sdk.crypto().initialize_user_crypto({ userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), - email: email, + email: masterKeySalt, accountCryptographicState: { V2: { private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, @@ -598,8 +600,11 @@ export class UserKeyRotationService { this.kdfConfigService.getKdfConfig$(user.id), "KDF config", ))!; - // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased. - const masterKeySalt = user.email.trim().toLowerCase(); + + const masterKeySalt = await this.firstValueFromOrThrow( + this.masterPasswordService.saltForUser$(user.id), + "Master key salt", + ); // V1 and V2 users both have a user key and a private key const currentUserKey: UserKey = (await this.firstValueFromOrThrow( diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d9eb03ea1ca..4da2d05f12b 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -10,7 +10,7 @@ import { OnInit, viewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { firstValueFrom, Observable, Subject, switchMap } from "rxjs"; import { map } from "rxjs/operators"; @@ -226,6 +226,9 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { ); protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + private readonly archiveFlagEnabled = toSignal(this.archiveFlagEnabled$, { + initialValue: false, + }); protected userId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -237,6 +240,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { switchMap((userId) => this.archiveService.userCanArchive$(userId)), ); + private readonly userCanArchive = toSignal(this.userCanArchive$, { initialValue: false }); + protected get isTrashFilter() { return this.filter?.type === "trash"; } @@ -293,14 +298,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher?.isArchived; } - private _userCanArchive = false; - protected get showArchiveOptions(): boolean { - return this._userCanArchive && !this.params.isAdminConsoleAction && this.params.mode === "view"; + return ( + this.archiveFlagEnabled() && !this.params.isAdminConsoleAction && this.params.mode === "view" + ); } protected get showArchiveBtn(): boolean { - return this.cipher?.canBeArchived; + return this.userCanArchive() && this.cipher?.canBeArchived; } protected get showUnarchiveBtn(): boolean { @@ -355,8 +360,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { takeUntilDestroyed(), ) .subscribe(); - - this.userCanArchive$.pipe(takeUntilDestroyed()).subscribe((v) => (this._userCanArchive = v)); } async ngOnInit() { diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index af4eb182eec..d2f5cc38013 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -76,7 +76,7 @@
implements OnInit, OnDestr await this.cipherArchiveService.archiveWithServer(cipherIds as CipherId[], activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemsWereSentToArchive"), + message: this.i18nService.t("bulkArchiveItems"), }); this.refresh(); } catch (e) { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fc2f463d9e6..59f5bc88419 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -47,8 +47,8 @@ "noEditPermissions": { "message": "You don't have permission to edit this item" }, - "reviewAtRiskPasswords": { - "message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords." + "reviewAccessIntelligence": { + "message": "Review security reports to find and fix credential risks before they escalate." }, "reviewAtRiskLoginsPrompt": { "message": "Review at-risk logins" @@ -268,6 +268,42 @@ } } }, + "numCriticalApplicationsMarkedSuccess": { + "message": "$COUNT$ applications marked critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "numApplicationsUnmarkedCriticalSuccess": { + "message": "$COUNT$ applications marked not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsCritical": { + "message": "Mark $COUNT$ as critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsNotCritical": { + "message": "Mark $COUNT$ as not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "applicationsMarkedAsCriticalFail": { "message": "Failed to mark applications as critical" }, @@ -6637,6 +6673,18 @@ } } }, + "reinviteSuccessToast":{ + "message": "1 invitation sent" + }, + "bulkReinviteSentToast": { + "message": "$COUNT$ invitations sent", + "placeholders": { + "count": { + "content": "$1", + "example": "12" + } + } + }, "bulkReinviteLimitedSuccessToast": { "message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.", "placeholders": { @@ -6654,6 +6702,50 @@ } } }, + "bulkReinviteProgressTitle": { + "message": "$COUNT$ of $TOTAL$ invitations sent...", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + }, + "total": { + "content": "$2", + "example": "2,000" + } + } + }, + "bulkReinviteProgressSubtitle": { + "message": "Keep this page open until all are sent." + }, + "bulkReinviteFailuresTitle": { + "message": "$COUNT$ invitations didn't send", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + } + } + }, + "bulkReinviteFailureTitle": { + "message": "1 invitation didn't send" + }, + "bulkReinviteFailureDescription":{ + "message": "An error occurred while sending invitations to $COUNT$ of $TOTAL$ members. Try sending again, and if the problem continues,", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + }, + "total": { + "content": "$2", + "example": "2,000" + } + } + }, + "bulkResendInvitations": { + "message": "Try sending again" + }, "bulkRemovedMessage": { "message": "Removed successfully" }, @@ -10092,6 +10184,9 @@ "assignTasks": { "message": "Assign tasks" }, + "allTasksAssigned": { + "message": "All tasks have been assigned" + }, "assignSecurityTasksToMembers": { "message": "Send notifications to change passwords" }, @@ -11804,9 +11899,6 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemsWereSentToArchive": { - "message": "Items were sent to archive" - }, "itemWasUnarchived": { "message": "Item was unarchived" }, @@ -12490,6 +12582,9 @@ "confirmNoSelectedCriticalApplicationsDesc": { "message": "Are you sure you want to continue?" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "userVerificationFailed": { "message": "User verification failed." }, @@ -12808,7 +12903,7 @@ "message": "Prices exclude tax and are billed annually." }, "invoicePreviewErrorMessage": { - "message": "Encountered an error while generating the invoice preview." + "message": "Encountered an error while generating the invoice preview." }, "planProratedMembershipInMonths": { "message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)", diff --git a/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md b/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md new file mode 100644 index 00000000000..2e45d7fa9d8 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md @@ -0,0 +1,807 @@ +# Report Data Model Evolution + +> **Purpose**: Document the old report data model (what's stored today), the updated model +> from PR #17356 (merged, follows BW architecture), and the target model with the member +> registry optimization. This is a reference for understanding why the report was 450MB+ +> and how the member registry solves it. + +--- + +## Table of Contents + +1. [Current Storage Model](#1-current-storage-model-still-in-use--plain-interfaces-no-architecture) +2. [Proposed View Models — Following BW Architecture](#2-proposed-view-models--following-bw-architecture-what-should-be-implemented-next) +3. [Target Model — With Member Registry](#3-target-model--with-member-registry-what-were-building) +4. [Storage Structure Comparison](#4-storage-structure-comparison) +5. [Encryption Approaches (Current vs Future Options)](#5-encryption-approaches-current-vs-future-options) + +--- + +## 1. Current Storage Model (Still In Use) — Plain interfaces, no architecture + +**Status:** This is what's stored in the database today. These are simple TypeScript interfaces/types with no domain/data/view/api layers. No encryption support in the types themselves. Services do all the filtering and transformation. + +**Note:** While PR #17356 introduced architecture patterns, the actual storage structure still uses these plain types directly. The proposed view models (Section 2) describe the architecture we should migrate to next. + +### ApplicationHealthReportDetail (the old report row) + +**Source:** `models/report-models.ts:78-88` (current implementation, still in use) + +**Current structure (with arrays):** + +```typescript +// This is the main report model — one record per application (grouped by URI hostname) +// Used directly by services and UI components +export type ApplicationHealthReportDetail = { + applicationName: string; // hostname (e.g. "google.com") + passwordCount: number; // total ciphers for this app + atRiskPasswordCount: number; // ciphers with weak/reused/exposed passwords + cipherIds: CipherId[]; // IDs of all ciphers in this app - ARRAY + atRiskCipherIds: CipherId[]; // IDs of at-risk ciphers - ARRAY (subset of cipherIds) + memberCount: number; // count of unique members (redundant, = memberDetails.length) + atRiskMemberCount: number; // count of at-risk members (redundant, = atRiskMemberDetails.length) + memberDetails: MemberDetails[]; // ⚠️ FULL member objects repeated per app - ARRAY + atRiskMemberDetails: MemberDetails[]; // ⚠️ FULL member objects for at-risk only (subset of memberDetails) - ARRAY + // Members are deduplicated within a single app but NOT across apps. +}; +``` + +**Proposed structure (with Records for consistency):** + +```typescript +export type ApplicationHealthReportDetail = { + applicationName: string; + passwordCount: number; + atRiskPasswordCount: number; + cipherRefs: Record; // true = at-risk, false = not at-risk (combines cipherIds + atRiskCipherIds) + memberCount: number; // could be removed (= Object.keys(memberRefs).length) + atRiskMemberCount: number; // could be removed (= count of true values in memberRefs) + memberRefs: Record; // true = at-risk, false = not at-risk (combines memberDetails + atRiskMemberDetails) +}; +``` + +**Benefits of Record pattern for ciphers:** + +- ✅ Combines `cipherIds` and `atRiskCipherIds` into single structure +- ✅ No duplicate IDs (prevents data inconsistency) +- ✅ O(1) lookup to check if cipher is at-risk +- ✅ Consistent with `memberRefs` pattern +- ✅ Saves space (~50 bytes per duplicate cipher ID in large orgs) + +### MemberDetails (the old member model) + +**Source:** `models/report-models.ts:16-21` (current implementation, still in use) + +```typescript +// Repeated in EVERY ApplicationHealthReportDetail that a member has access to +// For a large org with 5,000 members accessing 200 apps → duplicated across apps +export type MemberDetails = { + userGuid: string; // Organization user ID (UUID) + userName: string | null; // Display name + email: string; // Email address + cipherId: string; // ⚠️ Meaningless after deduplication (first cipher processed) +}; +``` + +### RiskInsightsData (the storage container) + +**Source:** `models/report-models.ts:121-128` (current implementation, still in use) + +**Current structure (with arrays):** + +**Rename to:** RiskInsights + +```typescript +// The top-level container that is stored in the database +// Each field is encrypted separately as an EncString +export interface RiskInsightsData { + id: OrganizationReportId; // Report ID (generated by API) + creationDate: Date; // When report was generated + contentEncryptionKey: EncString; // Key used to encrypt report data + reportData: ApplicationHealthReportDetail[]; // ⚠️ Main payload - can be 700MB+ + summaryData: OrganizationReportSummary; // Pre-computed aggregates (~1KB) + applicationData: OrganizationReportApplication[]; // Per-app settings (~10KB) - ARRAY with O(n) lookup +} +``` + +**Proposed structure (with Records for O(1) lookup):** + +```typescript +export interface RiskInsights { + id: OrganizationReportId; + creationDate: Date; + contentEncryptionKey: EncString; + reportData: ApplicationHealthReportDetail[]; // Array is still needed here for iteration + summaryData: OrganizationReportSummary; + applicationData: Record; // Record for O(1) lookup +} +``` + +**Current encryption:** Each of `reportData`, `summaryData`, and `applicationData` is JSON.stringify'd and encrypted as a separate EncString. For large orgs, `reportData` is compressed before encryption to avoid WASM size limits. + +### OrganizationReportApplication (per-app user settings) + +**Source:** `models/report-models.ts:64-72` (current implementation, still in use) + +**Current (Array):** Stored as array with O(n) lookup (inefficient) + +**Rename to:** RiskInsightsApplication (If separate model is needed) + +```typescript +// User-defined settings per application (critical flag, review date) +// Stored in the report, carried over between report generations +export type OrganizationReportApplication = { + applicationName: string; // hostname (e.g. "google.com") + isCritical: boolean; // user-defined critical flag + reviewedDate: Date | null; // null = new/unreviewed application +}; +``` + +**Proposed (Record):** Should be stored as Record for O(1) lookup + +```typescript +// Key = applicationName (hostname) +type ApplicationDataRecord = Record; + +// Example: +applicationData: { + "google.com": { isCritical: true, reviewedDate: new Date("2026-01-15") }, + "github.com": { isCritical: false, reviewedDate: null }, // new/unreviewed + "slack.com": { isCritical: true, reviewedDate: new Date("2026-02-01") } +} +``` + +**Problem with current array structure:** + +```typescript +// Current inefficient O(n) lookup pattern found in code: +getCriticalApplications(): RiskInsightsReportView[] { + return this.report.filter((app) => { + const appMeta = this.applications.find((a) => a.hostname === app.applicationName); // O(n)! + return appMeta?.isCritical === true; + }); +} +``` + +**With Record (O(1) lookup):** + +```typescript +getCriticalApplications(): RiskInsightsReportView[] { + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.isCritical === true; // O(1)! + }); +} +``` + +### OrganizationReportSummary (pre-computed aggregates) + +**Source:** `models/report-models.ts:49-58` (current implementation, still in use) + +**Rename to:** RiskInsightsSummary + +```typescript +// Pre-computed aggregates for summary cards and filtering +// Recomputed when critical application markings change +export type OrganizationReportSummary = { + totalMemberCount: number; // All members in org + totalApplicationCount: number; // All applications in report + totalAtRiskMemberCount: number; // Members with at-risk access + totalAtRiskApplicationCount: number; // Applications with at-risk ciphers + totalCriticalApplicationCount: number; // Applications marked critical + totalCriticalMemberCount: number; // Members with access to critical apps + totalCriticalAtRiskMemberCount: number; // Members with at-risk access to critical apps + totalCriticalAtRiskApplicationCount: number; // Critical apps with at-risk ciphers +}; +``` + +**Note:** When a user marks/unmarks an application as critical, the summary is recomputed. This is why `atRiskMemberDetails[]` is stored separately per application - it allows efficient recalculation of critical app summaries without reprocessing all cipher health data. + +### Why This Was 450MB+ + +The core problem: **`MemberDetails` objects were fully duplicated per application**. + +Example for a large org: + +- 5,000 org members +- 200 applications in the report +- Each member might have access to 50+ applications +- Each `MemberDetails` object ~200 bytes + +**Worst case**: 5,000 members × 50 apps × 200 bytes = ~50MB just for member data +in `memberDetails[]` arrays. With `atRiskMemberDetails[]` duplicated alongside, +plus cipher health data, this easily reached 450MB+. + +This caused: + +1. **WASM encryption panics** — the encrypted blob exceeded SDK size limits +2. **Database storage limits** — even compressed, the JSON was too large for DB fields +3. **Memory pressure** — holding this in a `BehaviorSubject` blocked the UI +4. **Slow report generation** — building all these duplicated member arrays was O(n²) + +--- + +## 2. Proposed View Models — Following BW Architecture (What Should Be Implemented Next) + +**Status:** PR #17356 laid groundwork for architecture patterns, but storage still uses plain types from Section 1. This section describes the view models that SHOULD be implemented to follow Bitwarden's 4-layer pattern: `Api → Data → Domain → View` + +**Important:** These models are NOT currently in use. They represent the target architecture we should migrate to, with query methods replacing facade/orchestrator filtering logic. + +### What's Stored (Current Implementation) + +The current implementation stores the **exact types from Section 1** above: + +- `ApplicationHealthReportDetail` - report rows (700MB+ for large orgs) - using ARRAYS +- `OrganizationReportApplication` - per-app settings (~10KB) - using ARRAY +- `OrganizationReportSummary` - aggregates (~1KB) + +These are stored in `RiskInsightsData` and encrypted as separate EncStrings: + +```typescript +// What gets stored in the database today (using arrays): +RiskInsightsData { + reportData: ApplicationHealthReportDetail[] // ← JSON.stringify → EncString + // Contains duplicate member objects across apps + // Contains duplicate cipher IDs (cipherIds + atRiskCipherIds) + summaryData: OrganizationReportSummary // ← JSON.stringify → EncString + applicationData: OrganizationReportApplication[] // ← JSON.stringify → EncString (array with O(n) lookup) + contentEncryptionKey: EncString + id: OrganizationReportId + creationDate: Date +} +``` + +**Encryption approach:** Each field is JSON.stringify'd, optionally compressed (for `reportData` only, to avoid WASM limits), then encrypted with the `contentEncryptionKey`. + +**Problems with current structure:** + +- Member objects duplicated across applications (576MB for 10K org) +- Cipher and member IDs duplicated in separate arrays (~70MB wasted) +- ApplicationData requires O(n) find operations for every lookup + +### Proposed View Models (For Query Logic) + +The new architecture will introduce domain/view models with query methods. These are **NOT stored** - they're runtime transformations of the stored data. + +#### RiskInsightsView (proposed - replaces facade logic) + +```typescript +class RiskInsightsView { + report: ApplicationHealthReportDetail[]; // Decrypted from storage + applications: OrganizationReportApplication[]; // Decrypted from storage + summary: OrganizationReportSummary; // Decrypted from storage + memberRegistry: MemberRegistry; // ← NEW: Built at load time + createdDate: Date; + + // Query methods (replace current facade/orchestrator filtering): + getAtRiskMembers(): MemberRegistryEntry[]; + getCriticalApplications(): ApplicationHealthReportDetail[]; + getApplicationByHostname(hostname: string): ApplicationHealthReportDetail | undefined; + getNewApplications(): ApplicationHealthReportDetail[]; // reviewedDate === null + getSummary(): OrganizationReportSummary; +} +``` + +**Note:** The view model will have query methods, but the underlying storage structure (Section 1) remains the same until we implement the member registry optimization (Section 3). + +--- + +## 3. Target Model — With Member Registry (What We're Building) + +**Key optimization:** Replace duplicated `MemberDetails[]` arrays with lightweight member ID references that point into a shared `MemberRegistry`. This reduces a 10K org report from ~786MB to ~173MB (78% reduction). + +**Storage changes:** + +- Store members ONCE in a registry (not per application) +- Store only member IDs (userGuids) in application records +- Remove meaningless `cipherId` field from member data +- Combine `memberDetails` and `atRiskMemberDetails` into single array with flag (OR keep separate arrays with IDs only) + +### MemberRegistry (new — deduplicated member lookup) + +```typescript +// Single source of truth for member data in a report +// Stored once, referenced by index from every application that member appears in +class MemberRegistry { + // Map from org user ID → full member entry + private entries: Map; + + get(id: OrganizationUserId): MemberRegistryEntry | undefined; + getAll(): MemberRegistryEntry[]; + size(): number; +} + +interface MemberRegistryEntry { + id: OrganizationUserId; + userName: string; + email: string; + // Any other member metadata needed by the UI +} +``` + +### Member References (new — Record with at-risk flag) + +Instead of duplicating full member objects per application, each application stores member IDs as a `Record`, where: + +- **Key** = member ID (userGuid) +- **Value** = `true` if at-risk, `false` if not at-risk + +This provides: + +- **O(1) lookup** for checking membership and at-risk status +- **Automatic deduplication** (can't have duplicate keys) +- **Single source** for both member list and at-risk status +- **No duplicate IDs** (previously stored in both memberDetails and atRiskMemberDetails) + +```typescript +// Stored as a Record where value indicates at-risk status +type MemberRefs = Record; + +// Example: +memberRefs: { + "abc-123": true, // at-risk member + "def-456": false, // not at-risk + "ghi-789": true // at-risk member +} +``` + +### Updated RiskInsightsReportView (with registry references) + +```typescript +class RiskInsightsReportView { + applicationName: string; + passwordCount: number; + atRiskPasswordCount: number; + weakPasswordCount: number; + reusedPasswordCount: number; + exposedPasswordCount: number; + + // OLD: memberDetails: MemberDetails[] + atRiskMemberDetails: MemberDetails[] (duplicated arrays) + // NEW: Single Record with at-risk flag + memberRefs: Record; // { "abc": true, "def": false, ... } + + // OLD: cipherIds: CipherId[] + atRiskCipherIds: CipherId[] (duplicated arrays) + // NEW: Single Record with at-risk flag + cipherRefs: Record; // { "cipher-1": true, "cipher-2": false, ... } + + // The registry is held by the parent RiskInsightsView + // View model methods resolve refs → full entries on demand: + + getAllMembers(registry: MemberRegistry): MemberRegistryEntry[] { + return Object.keys(this.memberRefs) + .map((id) => registry.get(id as OrganizationUserId)) + .filter(Boolean); + } + + getAtRiskMembers(registry: MemberRegistry): MemberRegistryEntry[] { + return Object.entries(this.memberRefs) + .filter(([_, isAtRisk]) => isAtRisk) + .map(([id]) => registry.get(id as OrganizationUserId)) + .filter(Boolean); + } + + isAtRisk(): boolean { + return this.atRiskPasswordCount > 0; + } + + hasMember(memberId: OrganizationUserId): boolean { + return memberId in this.memberRefs; // O(1) lookup + } + + isMemberAtRisk(memberId: OrganizationUserId): boolean { + return this.memberRefs[memberId] === true; // O(1) lookup + } +} +``` + +### Updated RiskInsightsView (parent, holds registry) + +```typescript +class RiskInsightsView { + report: RiskInsightsReportView[]; + applications: Record; + summary: RiskInsightsSummaryView; + memberRegistry: MemberRegistry; // ← shared, deduplicated + createdDate: Date; + + // Smart query methods — these replace facade/orchestrator filtering logic: + + getAtRiskMembers(): MemberRegistryEntry[] { + // Deduplicate across all at-risk apps + const ids = new Set(); + for (const app of this.report) { + if (app.isAtRisk()) { + // memberRefs is a Record, iterate entries and filter for at-risk (value === true) + Object.entries(app.memberRefs).forEach(([id, isAtRisk]) => { + if (isAtRisk) ids.add(id as OrganizationUserId); + }); + } + } + return [...ids].map((id) => this.memberRegistry.get(id)).filter(Boolean); + } + + getCriticalApplications(): RiskInsightsReportView[] { + // OLD (O(n)): this.applications.find((a) => a.hostname === app.applicationName) + // NEW (O(1)): this.applicationData[app.applicationName] + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.isCritical === true; + }); + } + + getApplicationByHostname(hostname: string): RiskInsightsReportView | undefined { + return this.report.find((app) => app.applicationName === hostname); + } + + getNewApplications(): RiskInsightsReportView[] { + // OLD (O(n)): this.applications.find((a) => a.hostname === app.applicationName) + // NEW (O(1)): this.applicationData[app.applicationName] + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.reviewedDate === null; + }); + } + + getSummary(): RiskInsightsSummaryView { + return this.summary; + } +} +``` + +### Size Impact: Current vs Target + +#### Current (700MB+ for large orgs) + +**10K member org:** + +- `memberDetails`: 400 apps × 5,000 members × 180 bytes = **360MB** +- `atRiskMemberDetails`: 400 apps × 3,000 members × 180 bytes = **216MB** +- Cipher IDs + metadata: **~15MB** +- **Total unencrypted: ~591MB** +- **After encryption + Base64: ~786MB** + +#### Target (With Registry + Record Pattern) + +**10K member org:** + +- **MemberRegistry**: 10,000 members × 140 bytes (no cipherId) = **1.4MB** (stored once) +- **memberRefs**: 400 apps × 5,000 refs × 50 bytes (Record entry: `"id": false/true`) = **100MB** + - No separate atRiskMemberRefs needed - at-risk status is the boolean value +- **cipherRefs**: 400 apps × 100 ciphers × 50 bytes (Record entry: `"id": false/true`) = **2MB** + - No separate atRiskCipherIds array needed - at-risk status is the boolean value +- **applicationData** (as Record): 400 apps × 100 bytes = **0.04MB** (negligible) +- Metadata (counts, applicationName): **~10MB** +- **Total unencrypted: ~113MB** +- **After encryption + Base64: ~150MB** + +**Reduction: 786MB → 150MB = 81% smaller** 🎉 + +**Design Decision:** Use single `Record` for members, ciphers, AND Record for applicationData: + +- **memberRefs:** No duplicate member IDs, ~60MB saved (vs separate atRiskMemberDetails) +- **cipherRefs:** No duplicate cipher IDs, ~10MB saved (vs separate atRiskCipherIds array) +- **applicationData:** O(1) lookup, no functional size change but better performance +- **Trade-off:** Definitely worth it - saves ~70MB and prevents duplicate storage + +--- + +## 4. Storage Structure Comparison + +### Current Storage (What's in DB Today) + +```typescript +// Stored as RiskInsightsData in database +{ + id: OrganizationReportId, + creationDate: Date, + contentEncryptionKey: EncString, + + // ENCRYPTED FIELD 1: reportData (~700MB for large orgs) + reportData: [ + { + applicationName: "google.com", + cipherIds: ["cipher-id-1", "cipher-id-2", ...], // ~100 ciphers - ARRAY + atRiskCipherIds: ["cipher-id-1", ...], // ~50 at-risk - ARRAY (duplicates IDs from cipherIds) + memberDetails: [ // ~5,000 members - ARRAY + { userGuid: "abc", userName: "Alice", email: "alice@...", cipherId: "x" }, + { userGuid: "def", userName: "Bob", email: "bob@...", cipherId: "y" }, + // ... FULL member objects, deduplicated per app, duplicated across apps + ], + atRiskMemberDetails: [ // ~3,000 at-risk members - ARRAY (duplicates from memberDetails) + { userGuid: "abc", userName: "Alice", email: "alice@...", cipherId: "x" }, + // ... FULL member objects (subset of memberDetails) + ], + passwordCount: 100, + atRiskPasswordCount: 50, + memberCount: 5000, + atRiskMemberCount: 3000 + }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 2: applicationData (~10KB) - ARRAY with O(n) lookup + applicationData: [ + { applicationName: "google.com", isCritical: true, reviewedDate: Date | null }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 3: summaryData (~1KB) + summaryData: { + totalMemberCount: 10000, + totalApplicationCount: 400, + totalAtRiskMemberCount: 6000, + totalAtRiskApplicationCount: 300, + totalCriticalApplicationCount: 50, + totalCriticalMemberCount: 8000, + totalCriticalAtRiskMemberCount: 4500, + totalCriticalAtRiskApplicationCount: 40 + } +} +``` + +**Total size:** ~786MB encrypted for 10K member org +**Problem:** Member data duplicated across applications (360MB + 216MB = 576MB just for members) + +--- + +### Target Storage (With Member Registry) + +```typescript +// Stored as RiskInsightsData in database +{ + id: OrganizationReportId, + creationDate: Date, + contentEncryptionKey: EncString, + + // NEW: ENCRYPTED FIELD 0: memberRegistry (~1.4MB for 10K members) + memberRegistry: { + "abc": { userGuid: "abc", userName: "Alice", email: "alice@..." }, + "def": { userGuid: "def", userName: "Bob", email: "bob@..." }, + // ... 10,000 members stored ONCE + }, + + // ENCRYPTED FIELD 1: reportData (~116MB for 10K org - 80% reduction!) + reportData: [ + { + applicationName: "google.com", + cipherRefs: { // ~100 cipher IDs with at-risk flag - RECORD + "cipher-id-1": true, // at-risk + "cipher-id-2": false, // not at-risk + "cipher-id-3": true, // at-risk + // ... (no separate atRiskCipherIds array needed) + }, + memberRefs: { // ~5,000 member IDs with at-risk flag - RECORD + "abc": true, // at-risk member + "def": false, // not at-risk + "ghi": true, // at-risk member + // ... (no separate atRiskMemberRefs array needed) + }, + passwordCount: 100, + atRiskPasswordCount: 50, + memberCount: 5000, + atRiskMemberCount: 3000 + }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 2: applicationData (~10KB) - RECORD with O(1) lookup + applicationData: { + "google.com": { isCritical: true, reviewedDate: new Date("2026-01-15") }, + "github.com": { isCritical: false, reviewedDate: null }, + "slack.com": { isCritical: true, reviewedDate: new Date("2026-02-01") } + // ... 400 applications as Record entries + }, + + // ENCRYPTED FIELD 3: summaryData (~1KB - unchanged) + summaryData: { + totalMemberCount: 10000, + totalApplicationCount: 400, + totalAtRiskMemberCount: 6000, + totalAtRiskApplicationCount: 300, + totalCriticalApplicationCount: 50, + totalCriticalMemberCount: 8000, + totalCriticalAtRiskMemberCount: 4500, + totalCriticalAtRiskApplicationCount: 40 + } +} +``` + +**Total size:** ~150MB encrypted for 10K member org (81% reduction) +**Benefits:** + +- Members stored once in registry, referenced by ID from applications +- Member and cipher IDs stored with at-risk flag (no duplicate arrays) +- ApplicationData as Record enables O(1) lookup instead of O(n) find operations + +--- + +### Design Decision: Single Record with Boolean Flag (for Members AND Ciphers) + +**Chosen approach:** Use single `Record` where the boolean indicates at-risk status for BOTH members and ciphers. + +```typescript +{ + applicationName: "google.com", + memberRefs: { + "abc": true, // at-risk member + "def": false, // not at-risk + "ghi": true // at-risk member + }, + cipherRefs: { + "cipher-1": true, // at-risk cipher + "cipher-2": false, // not at-risk + "cipher-3": true // at-risk cipher + } +} +``` + +**Pros:** + +- ✅ **Members:** No duplicate IDs (previously stored in both memberDetails AND atRiskMemberDetails) +- ✅ **Ciphers:** No duplicate IDs (previously stored in both cipherIds AND atRiskCipherIds) +- ✅ O(1) lookup for both membership/presence and at-risk status +- ✅ Automatic deduplication (can't have duplicate keys) +- ✅ Saves ~60MB for members + ~10MB for ciphers = **~70MB saved** compared to separate arrays +- ✅ Clear intent - one ID, one entry, one flag +- ✅ Consistent pattern across both members and ciphers + +**Cons:** + +- ⚠️ Slightly more complex iteration (need to check boolean value when filtering at-risk) +- ⚠️ Summary recalculation requires iterating entries instead of just counting keys + +**Trade-off Analysis:** + +- **Member size savings:** ~60MB (400 apps × 3K at-risk IDs × 50 bytes per duplicate entry) +- **Cipher size savings:** ~10MB (400 apps × 50 at-risk IDs × 50 bytes per duplicate entry) +- **Total savings:** ~70MB for 10K org +- **Performance:** Negligible - `Object.entries().filter()` is still O(n) like array iteration +- **Correctness:** Better - impossible to have ID in at-risk array but not in main array + +**Verdict:** Single Record with boolean flag is the clear winner for both members AND ciphers. + +--- + +## 5. Encryption Approaches (Current vs Future Options) + +### Current: Encrypt Whole Objects (No Compression) + +**How it works:** + +1. `JSON.stringify(reportData)` → encrypt → EncString (~700MB for large orgs) +2. `JSON.stringify(summaryData)` → encrypt → EncString (~1KB) +3. `JSON.stringify(applicationData)` → encrypt → EncString (~10KB) + +**Stored structure:** + +```typescript +{ + reportData: EncString, // ← Entire reportData[] array as one encrypted blob + summaryData: EncString, // ← Entire summary object as one encrypted blob + applicationData: EncString // ← Entire applicationData[] array as one encrypted blob + contentEncryptionKey: EncString, + id: OrganizationReportId, + creationDate: Date +} +``` + +**Problems:** + +- For large orgs (700MB+), may approach or exceed WASM encryption limits +- Must decrypt entire report to access any application +- Can't do field-level encryption with this approach + +--- + +### Option 1: Encrypt Per Top-Level Field (Current Approach) + +Encrypt `reportData`, `summaryData`, `applicationData` as separate EncStrings. + +**Pros:** + +- ✅ Allows decrypting summary without decrypting full report +- ✅ Simple encryption logic +- ✅ Separates metadata (summary, applicationData) from payload (reportData) + +**Cons:** + +- ❌ Can't access individual applications without decrypting entire report +- ❌ Can't do field-level encryption +- ❌ May hit WASM limits for very large orgs (700MB+ unencrypted) + +**Status:** This is what we have today. + +--- + +### Option 2: True Field-Level Encryption (Ideal) + +**Each field** within each object is encrypted separately, preserving JSON structure: + +```typescript +{ + memberRegistry: { + "abc": { + userGuid: EncString("abc"), + userName: EncString("Alice"), + email: EncString("alice@...") + }, + "def": { /* ... */ } + }, + reportData: [ + { + applicationName: EncString("google.com"), + cipherIds: [EncString("id1"), EncString("id2"), ...], + memberRefs: [EncString("abc"), EncString("def"), ...], + atRiskMemberRefs: [EncString("abc"), ...], + passwordCount: EncString("100"), + atRiskPasswordCount: EncString("50"), + memberCount: EncString("5000"), + atRiskMemberCount: EncString("3000") + }, + // ... each application + ], + summaryData: { + totalMemberCount: EncString("10000"), + totalApplicationCount: EncString("400"), + // ... each field encrypted + }, + applicationData: [ + { + applicationName: EncString("google.com"), + isCritical: EncString("true"), + reviewedDate: EncString("2026-02-10") + }, + // ... each application + ] +} +``` + +**Pros:** + +- ✅ Can decrypt individual fields on-demand +- ✅ Can access single application without decrypting all +- ✅ Each field is small enough for SDK (no size limits) +- ✅ Better for partial updates (re-encrypt only changed fields) +- ✅ Aligns with Bitwarden's data model architecture + +**Cons:** + +- ❌ More complex encryption/decryption logic +- ❌ Slightly larger overhead (each EncString has IV + metadata ~20 bytes) +- ❌ Requires updating all encryption/decryption code paths + +**Status:** **Can be implemented alongside member registry.** The member registry will reduce report size (making this easier), but field-level encryption is not blocked by it. + +**Size estimate with field-level encryption:** + +- Member registry (10K members): ~1.4MB unencrypted → ~2MB encrypted (each field encrypted) +- Report data: ~116MB unencrypted → ~145MB encrypted (overhead from EncString metadata) +- **Total: ~147MB** (vs ~154MB with whole-object encryption) + +Field-level encryption adds ~10MB overhead but enables partial decryption and avoids WASM limits. + +--- + +### Option 3: Compress Then Encrypt (Draft PR, Want to Avoid) + +Compress `reportData` before encrypting. `summaryData` and `applicationData` remain uncompressed (TBD if `applicationData` needs compression). + +**How it would work:** + +1. `JSON.stringify(reportData)` → compress with pako → encrypt → EncString +2. `JSON.stringify(summaryData)` → encrypt → EncString (no compression) +3. `JSON.stringify(applicationData)` → encrypt → EncString (compression TBD) + +**Pros:** + +- ✅ Stored object is as small as we can get it (compression reduces size by ~70%) +- ✅ Works for very large orgs without hitting WASM limits + +**Cons:** + +- ❌ Can't decrypt summary without decompressing everything if whole object is compressed +- ❌ Makes field-level encryption impossible (can't decrypt individual fields from compressed blob) +- ❌ More complex decryption logic (decompress → decrypt) +- ❌ Not the direction we want to go architecturally + +**Decision:** **Avoid if possible.** This was explored in a draft PR as a workaround, but we'd prefer to implement member registry (reduces size without compression) and move toward field-level encryption (Option 2). diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 88af8081a8b..886ae4c5008 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -228,7 +228,7 @@ export class RiskInsightsOrchestratorService { * @param criticalApplication Application name of the critical application to remove * @returns */ - removeCriticalApplication$(criticalApplication: string): Observable { + removeCriticalApplications$(applicationsToUnmark: Set): Observable { this.logService.info( "[RiskInsightsOrchestratorService] Removing critical applications from report", ); @@ -245,11 +245,10 @@ export class RiskInsightsOrchestratorService { throwError(() => Error("Tried to update critical applications without a report")); } - // Create a set for quick lookup of the new critical apps const existingApplicationData = report!.applicationData || []; - const updatedApplicationData = this._removeCriticalApplication( + const updatedApplicationData = this._removeCriticalApplications( existingApplicationData, - criticalApplication, + applicationsToUnmark, ); // Updated summary data after changing critical apps @@ -917,12 +916,12 @@ export class RiskInsightsOrchestratorService { } // Toggles the isCritical flag on applications via criticalApplicationName - private _removeCriticalApplication( + private _removeCriticalApplications( applicationData: OrganizationReportApplication[], - criticalApplication: string, + applicationsToUnmark: Set, ): OrganizationReportApplication[] { const updatedApplicationData = applicationData.map((application) => { - if (application.applicationName == criticalApplication) { + if (applicationsToUnmark.has(application.applicationName)) { return { ...application, isCritical: false } as OrganizationReportApplication; } return application; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index d426a6b09c1..8cf799250f2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -263,8 +263,8 @@ export class RiskInsightsDataService { return this.orchestrator.saveCriticalApplications$(selectedUrls); } - removeCriticalApplication(hostname: string) { - return this.orchestrator.removeCriticalApplication$(hostname); + removeCriticalApplications(selectedUrls: Set) { + return this.orchestrator.removeCriticalApplications$(selectedUrls); } saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) { diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts index 75c61e3e7e3..7e6e346d3b7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts @@ -1,10 +1,6 @@ import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { map, Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicyEditDefinition, BasePolicyEditComponent, @@ -16,12 +12,6 @@ export class BlockClaimedDomainAccountCreationPolicy extends BasePolicyEditDefin description = "blockClaimedDomainAccountCreationDesc"; type = PolicyType.BlockClaimedDomainAccountCreation; component = BlockClaimedDomainAccountCreationPolicyComponent; - - override display$(organization: Organization, configService: ConfigService): Observable { - return configService - .getFeatureFlag$(FeatureFlag.BlockClaimedDomainAccountCreation) - .pipe(map((enabled) => enabled && organization.useOrganizationDomains)); - } } @Component({ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts index e581bf458d2..1b1ae25c027 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -215,7 +215,10 @@ export class MembersComponent extends BaseMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index 3efeee17100..c63bda449c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -228,7 +228,10 @@ export class vNextMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 5592e4cc546..555d6aa62e0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -59,7 +59,7 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. safeProvider({ provide: AccessIntelligenceSecurityTasksService, useClass: AccessIntelligenceSecurityTasksService, - deps: [DefaultAdminTaskService, SecurityTasksApiService], + deps: [DefaultAdminTaskService, SecurityTasksApiService, RiskInsightsDataService], }), safeProvider({ provide: PasswordHealthService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index df47adb4635..0487ae726e3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -20,7 +20,7 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; import { ButtonModule, @@ -57,10 +57,14 @@ export class PasswordChangeMetricComponent implements OnInit { // Signal states private readonly _tasks: Signal = signal([]); - private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); - private readonly _reportGeneratedAt: Signal = signal( - undefined, + private readonly _unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + private readonly _atRiskCipherIds = toSignal( + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + { initialValue: [] }, ); // Computed properties @@ -74,41 +78,11 @@ export class PasswordChangeMetricComponent implements OnInit { return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; }); - readonly unassignedCipherIds = computed(() => { - const atRiskIds = this._atRiskCipherIds(); - const tasks = this._tasks(); + readonly unassignedCipherIds = computed(() => this._unassignedCipherIds().length); - if (tasks.length === 0) { - return atRiskIds.length; - } - - const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); - - const reportGeneratedAt = this._reportGeneratedAt(); - const completedTasksAfterReportGeneration = reportGeneratedAt - ? tasks.filter( - (task) => - task.status === SecurityTaskStatus.Completed && - new Date(task.revisionDate) >= reportGeneratedAt, - ) - : []; - const completedTaskIds = new Set( - completedTasksAfterReportGeneration.map((task) => task.cipherId), - ); - - // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task - const unassignedIds = atRiskIds.filter( - (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), - ); - - return unassignedIds.length; - }); - - readonly atRiskPasswordCount = computed(() => { + readonly atRiskPasswordCount = computed(() => { const atRiskIds = this._atRiskCipherIds(); const atRiskIdsSet = new Set(atRiskIds); - return atRiskIdsSet.size; }); @@ -119,7 +93,7 @@ export class PasswordChangeMetricComponent implements OnInit { if (this.tasksCount() === 0) { return PasswordChangeView.NO_TASKS_ASSIGNED; } - if (this.unassignedCipherIds() > 0) { + if (this._unassignedCipherIds().length > 0) { return PasswordChangeView.NEW_TASKS_AVAILABLE; } return PasswordChangeView.PROGRESS; @@ -133,10 +107,6 @@ export class PasswordChangeMetricComponent implements OnInit { private toastService: ToastService, ) { this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); - this._atRiskCipherIds = toSignal( - this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { initialValue: [] }, - ); this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( map((report) => { @@ -145,10 +115,6 @@ export class PasswordChangeMetricComponent implements OnInit { ), { initialValue: false }, ); - this._reportGeneratedAt = toSignal( - this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), - { initialValue: undefined }, - ); effect(() => { const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS; @@ -164,7 +130,7 @@ export class PasswordChangeMetricComponent implements OnInit { try { await this.securityTasksService.requestPasswordChangeForCriticalApplications( this.organizationId(), - this._atRiskCipherIds(), + this._unassignedCipherIds(), ); this.toastService.showToast({ message: this.i18nService.t("notifiedMembers"), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index efe07d50683..27864fa2f87 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -21,27 +21,58 @@ > - +
+ @if (selectedUrls().size > 0) { + @if (allSelectedAppsAreCritical()) { + + } @else { + + } + } - + + + +
{ let mockLogService: MockProxy; let mockToastService: MockProxy; let mockDataService: MockProxy; + let mockSecurityTasksService: MockProxy; const reportStatus$ = new BehaviorSubject(ReportStatus.Complete); const enrichedReportData$ = new BehaviorSubject(null); @@ -47,6 +49,7 @@ describe("ApplicationsComponent", () => { appAtRiskMembers: null, atRiskAppDetails: null, }); + const unassignedCriticalCipherIds$ = new BehaviorSubject([]); beforeEach(async () => { mockI18nService = mock(); @@ -54,6 +57,7 @@ describe("ApplicationsComponent", () => { mockLogService = mock(); mockToastService = mock(); mockDataService = mock(); + mockSecurityTasksService = mock(); mockI18nService.t.mockImplementation((key: string) => key); @@ -65,6 +69,9 @@ describe("ApplicationsComponent", () => { get: () => criticalReportResults$, }); Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ }); + Object.defineProperty(mockSecurityTasksService, "unassignedCriticalCipherIds$", { + get: () => unassignedCriticalCipherIds$, + }); await TestBed.configureTestingModule({ imports: [ApplicationsComponent, ReactiveFormsModule], @@ -78,6 +85,7 @@ describe("ApplicationsComponent", () => { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: (): string | null => null } } }, }, + { provide: AccessIntelligenceSecurityTasksService, useValue: mockSecurityTasksService }, ], }).compileComponents(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts index b5fae36bb2e..0020106ba7d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -7,10 +7,10 @@ import { signal, computed, } from "@angular/core"; -import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toObservable, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, startWith } from "rxjs"; +import { combineLatest, debounceTime, EMPTY, map, startWith, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -22,6 +22,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, IconButtonModule, @@ -30,8 +31,10 @@ import { SearchModule, TableDataSource, ToastService, + TooltipDirective, TypographyModule, ChipSelectComponent, + IconComponent, } from "@bitwarden/components"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; @@ -42,6 +45,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; import { ReportLoadingComponent } from "../shared/report-loading.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; export const ApplicationFilterOption = { All: "all", @@ -70,6 +74,8 @@ export type ApplicationFilterOption = ButtonModule, ReactiveFormsModule, ChipSelectComponent, + IconComponent, + TooltipDirective, ], }) export class ApplicationsComponent implements OnInit { @@ -86,13 +92,14 @@ export class ApplicationsComponent implements OnInit { // Template driven properties protected readonly selectedUrls = signal(new Set()); - protected readonly markingAsCritical = signal(false); + protected readonly updatingCriticalApps = signal(false); protected readonly applicationSummary = signal(createNewSummaryData()); protected readonly criticalApplicationsCount = signal(0); protected readonly totalApplicationsCount = signal(0); protected readonly nonCriticalApplicationsCount = computed(() => { return this.totalApplicationsCount() - this.criticalApplicationsCount(); }); + protected readonly organizationId = signal(undefined); // filter related properties protected readonly selectedFilter = signal(ApplicationFilterOption.All); @@ -112,14 +119,46 @@ export class ApplicationsComponent implements OnInit { ]); protected readonly emptyTableExplanation = signal(""); + readonly allSelectedAppsAreCritical = computed(() => { + if (!this.dataSource.filteredData || this.selectedUrls().size == 0) { + return false; + } + + return this.dataSource.filteredData + .filter((row) => this.selectedUrls().has(row.applicationName)) + .every((row) => row.isMarkedAsCritical); + }); + + protected readonly unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + + readonly enableRequestPasswordChange = computed(() => this.unassignedCipherIds().length > 0); + constructor( protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, protected dataService: RiskInsightsDataService, + protected securityTasksService: AccessIntelligenceSecurityTasksService, ) {} async ngOnInit() { + this.activatedRoute.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap(async (orgId) => { + if (orgId) { + this.organizationId.set(orgId as OrganizationId); + } else { + return EMPTY; + } + }), + ) + .subscribe(); + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { if (report != null) { @@ -193,12 +232,8 @@ export class ApplicationsComponent implements OnInit { this.selectedFilter.set(value); } - isMarkedAsCriticalItem(applicationName: string) { - return this.selectedUrls().has(applicationName); - } - markAppsAsCritical = async () => { - this.markingAsCritical.set(true); + this.updatingCriticalApps.set(true); const count = this.selectedUrls().size; this.dataService @@ -209,10 +244,10 @@ export class ApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + message: this.i18nService.t("numCriticalApplicationsMarkedSuccess", count), }); this.selectedUrls.set(new Set()); - this.markingAsCritical.set(false); + this.updatingCriticalApps.set(false); }, error: () => { this.toastService.showToast({ @@ -224,6 +259,65 @@ export class ApplicationsComponent implements OnInit { }); }; + unmarkAppsAsCritical = async () => { + this.updatingCriticalApps.set(true); + const appsToUnmark = this.selectedUrls(); + + this.dataService + .removeCriticalApplications(appsToUnmark) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t( + "numApplicationsUnmarkedCriticalSuccess", + appsToUnmark.size, + ), + variant: "success", + }); + this.selectedUrls.set(new Set()); + this.updatingCriticalApps.set(false); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, + }); + }; + + async requestPasswordChange() { + const orgId = this.organizationId(); + if (!orgId) { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + return; + } + + try { + await this.securityTasksService.requestPasswordChangeForCriticalApplications( + orgId, + this.unassignedCipherIds(), + ); + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + } + } + showAppAtRiskMembers = async (applicationName: string) => { await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 3033bf139c3..7180d50fe05 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -131,7 +131,7 @@ export class CriticalApplicationsComponent implements OnInit { removeCriticalApplication = async (hostname: string) => { this.dataService - .removeCriticalApplication(hostname) + .removeCriticalApplications(new Set([hostname])) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 1e58d334288..169c5d920ff 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -46,7 +46,7 @@
@if (appsCount > 0) {
- {{ "reviewAtRiskPasswords" | i18n }} + {{ "reviewAccessIntelligence" | i18n }}
}
+ } @else { + + + + + + + {{ + "criticalApplicationsWithCount" + | i18n + : (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + + } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - -
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html index 29da8a7a818..05dec048328 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -10,6 +10,7 @@ [bitTooltip]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)" (change)="selectAllChanged($event.target)" [attr.aria-label]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)" + [disabled]="dataSource().filteredData?.length === 0" /> diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index f6fb41cdbb0..4ee784337de 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { + RiskInsightsDataService, + SecurityTasksApiService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -13,12 +16,14 @@ describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; const defaultAdminTaskServiceMock = mock(); const securityTasksApiServiceMock = mock(); + const riskInsightsDataServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( defaultAdminTaskServiceMock, securityTasksApiServiceMock, + riskInsightsDataServiceMock, ); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 688ab039ca9..65a31896341 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -1,8 +1,10 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -14,10 +16,57 @@ export class AccessIntelligenceSecurityTasksService { private _tasksSubject$ = new BehaviorSubject([]); tasks$ = this._tasksSubject$.asObservable(); + /** + * Observable stream of unassigned critical cipher IDs. + * Returns cipher IDs from critical applications that don't have an associated task + * (either pending or completed after the report was generated). + */ + readonly unassignedCriticalCipherIds$: Observable; + constructor( private adminTaskService: DefaultAdminTaskService, private securityTasksApiService: SecurityTasksApiService, - ) {} + private riskInsightsDataService: RiskInsightsDataService, + ) { + this.unassignedCriticalCipherIds$ = combineLatest([ + this.tasks$, + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + this.riskInsightsDataService.enrichedReportData$, + ]).pipe( + map(([tasks, atRiskCipherIds, reportData]) => { + // If no tasks exist, return all at-risk cipher IDs + if (tasks.length === 0) { + return atRiskCipherIds; + } + + // Get in-progress tasks (awaiting password reset) + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + // Get completed tasks after report generation + const reportGeneratedAt = reportData?.creationDate; + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // Filter out cipher IDs that have a corresponding in-progress or completed task + return atRiskCipherIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); + }), + shareReplay({ + bufferSize: 1, + refCount: true, + }), + ); + } /** * Gets security task metrics for the given organization diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9941e7671f4..4db9ff37d42 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,9 +12,9 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", - BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", + BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements", /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", @@ -72,9 +72,11 @@ export enum FeatureFlag { BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", + PM31039ItemActionInExtension = "pm-31039-item-action-in-extension", /* Platform */ ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", + WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -106,9 +108,9 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, - [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, + [FeatureFlag.BulkReinviteUI]: FALSE, /* Autofill */ [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, @@ -116,6 +118,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, [FeatureFlag.SSHAgentV2]: FALSE, + [FeatureFlag.PM31039ItemActionInExtension]: FALSE, /* Tools */ [FeatureFlag.UseSdkPasswordGenerators]: FALSE, @@ -169,6 +172,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, + [FeatureFlag.WebAuthnRelatedOrigins]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/platform/services/fido2/domain-utils.spec.ts b/libs/common/src/platform/services/fido2/domain-utils.spec.ts index 284555052dd..df6132bac20 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,101 +2,377 @@ import { isValidRpId } from "./domain-utils"; // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. describe("validateRpId", () => { - it("should not be valid when rpId is null", () => { - const origin = "example.com"; + let mockFetch: jest.Mock; + let webAuthnRelatedOriginsFeatureFlag = false; - expect(isValidRpId(null, origin)).toBe(false); + beforeEach(() => { + mockFetch = jest.fn(); + // Default: ROR requests fail (no .well-known/webauthn endpoint) + mockFetch.mockRejectedValue(new Error("Network error")); }); - it("should not be valid when origin is null", () => { - const rpId = "example.com"; + describe("classic domain validation", () => { + it("should not be valid when rpId is null", async () => { + const origin = "example.com"; - expect(isValidRpId(rpId, null)).toBe(false); + expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when origin is null", async () => { + const rpId = "example.com"; + + expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when rpId is more specific than origin", async () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when effective domains of rpId and origin do not match", async () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are both different TLD", async () => { + const rpId = "bitwarden"; + const origin = "localhost"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + // Only allow localhost for rpId, need to properly investigate the implications of + // adding support for ip-addresses and other TLDs + it("should not be valid when rpId and origin are both the same TLD", async () => { + const rpId = "bitwarden"; + const origin = "bitwarden"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are ip-addresses", async () => { + const rpId = "127.0.0.1"; + const origin = "127.0.0.1"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should be valid when domains of rpId and origin are localhost", async () => { + const rpId = "localhost"; + const origin = "https://localhost:8080"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId", async () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not be valid for a partial match of a subdomain", async () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); }); - it("should not be valid when rpId is more specific than origin", () => { - const rpId = "sub.login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + describe("Related Origin Requests (ROR)", () => { + // Helper to create a mock fetch response + function mockRorResponse(origins: string[], status = 200, contentType = "application/json") { + mockFetch.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": contentType }), + json: async () => ({ origins }), + }); + } - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not proceed with ROR check when valid when feature flag disabled", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when effective domains of rpId and origin do not match", () => { - const rpId = "passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false); + expect(mockFetch).not.toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { - const rpId = "login.passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + webAuthnRelatedOriginsFeatureFlag = true; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should be valid when origin is listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when rpId and origin are both different TLD", () => { - const rpId = "bitwarden"; - const origin = "https://localhost"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - // Only allow localhost for rpId, need to properly investigate the implications of - // adding support for ip-addresses and other TLDs - it("should not be valid when rpId and origin are both the same TLD", () => { - const rpId = "bitwarden"; - const origin = "https://bitwarden"; + it("should not be valid when origin is not listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://evil.com"; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]); - it("should not be valid when rpId and origin are ip-addresses", () => { - const rpId = "127.0.0.1"; - const origin = "https://127.0.0.1"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not be valid when .well-known/webauthn returns non-200 status", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are localhost", () => { - const rpId = "localhost"; - const origin = "https://localhost:8080"; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when domains of rpId and origin are the same", () => { - const rpId = "bitwarden.com"; - const origin = "https://bitwarden.com"; + it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([origin], 200, "text/html"); - it("should be valid when origin is a subdomain of rpId", () => { - const rpId = "bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + it("should not be valid when .well-known/webauthn response has no origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ notOrigins: "invalid" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://sub.login.bitwarden.com:1337"; + it("should not be valid when .well-known/webauthn response has empty origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([]); - it("should not be valid for a partial match of a subdomain", () => { - const rpId = "accounts.example.com"; - const origin = "https://evilaccounts.example.com"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); + it("should not be valid when .well-known/webauthn response has non-string origins", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ origins: [123, { url: origin }] }), + }); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch throws an error", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new Error("Network error")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch times out", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should skip classic validation and use ROR when domains do not match", async () => { + // This is the Facebook/Meta use case + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockRorResponse([origin]); + + // Classic validation would fail (different domains), but ROR should succeed + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not call ROR endpoint when classic validation succeeds", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + // Classic validation succeeds, so ROR should not be called + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should require exact origin match (including port)", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com:8443"; + + // Only the non-port version is listed + mockRorResponse(["https://accountscenter.facebook.com"]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should handle invalid URLs in origins array gracefully", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + origins: ["not-a-valid-url", "://also-invalid", origin], + }), + }); + + // Should still find the valid origin despite invalid entries + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should enforce max labels limit", async () => { + const rpId = "example.com"; + const origin = "https://site6.com"; + + // Create origins from 6 different eTLD+1 labels + // Only the first 5 should be processed + mockRorResponse([ + "https://site1.com", + "https://site2.com", + "https://site3.com", + "https://site4.com", + "https://site5.com", + "https://site6.com", // This is the 6th label, should be skipped + ]); + + // The origin is in the list but should be skipped due to max labels limit + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should allow multiple origins from the same eTLD+1", async () => { + const rpId = "example.com"; + const origin = "https://sub2.facebook.com"; + + // All these are from facebook.com (same eTLD+1), so they count as 1 label + mockRorResponse([ + "https://www.facebook.com", + "https://sub1.facebook.com", + "https://sub2.facebook.com", + "https://sub3.facebook.com", + ]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 542beae3435..dafc270ea9a 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,22 +1,39 @@ import { parse } from "tldts"; /** - * Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests. + * This limit prevents malicious servers from causing excessive processing. + * Per WebAuthn spec recommendation. + */ +const ROR_MAX_LABELS = 5; + +/** + * Timeout in milliseconds for fetching the .well-known/webauthn endpoint. + */ +const ROR_FETCH_TIMEOUT_MS = 5000; + +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic + * WebAuthn specifications (before Related Origin Requests extension). * - * The validation enforces the following rules: - * - The origin must use the HTTPS scheme + * This implements the core WebAuthn RP ID validation logic: + * - The origin must use the HTTPS scheme (except localhost) * - Both rpId and origin must be valid domain names (not IP addresses) - * - Both must have the same registrable domain (e.g., example.com) + * - Both must have the same registrable domain (eTLD+1) * - The origin must either exactly match the rpId or be a subdomain of it * - Single-label domains are rejected unless they are 'localhost' * - Localhost is always valid when both rpId and origin are localhost * + * This is used internally as the first validation step before falling back to + * Related Origin Requests (ROR) validation. + * + * @see https://www.w3.org/TR/webauthn-2/#rp-id + * * @param rpId - The Relying Party identifier to validate * @param origin - The origin URL to validate against (must start with https://) * @returns `true` if the rpId is valid for the given origin, `false` otherwise - * */ -export function isValidRpId(rpId: string, origin: string) { +function isValidRpIdInternal(rpId: string, origin: string) { if (!rpId || !origin) { return false; } @@ -73,6 +90,148 @@ export function isValidRpId(rpId: string, origin: string) { if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) { return true; } - - return false; +} + +/** + * Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR). + * This implements the WebAuthn Related Origin Requests spec which allows an RP to + * authorize origins from different domains to use its rpId. + * + * @see https://w3c.github.io/webauthn/#sctn-related-origins + * + * @param rpId - The relying party ID being requested + * @param origin - The origin making the WebAuthn request + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise + */ +async function isAllowedByRor( + rpId: string, + origin: string, + fetchFn?: typeof fetch, +): Promise { + try { + const fetchImpl = fetchFn ?? globalThis.fetch; + + // Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController + let signal: AbortSignal; + if (typeof AbortSignal.timeout === "function") { + signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS); + } else { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS); + signal = controller.signal; + } + + const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, { + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + + if (!response.ok) { + return false; + } + + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + return false; + } + + const data = (await response.json()) as { origins?: unknown }; + + if ( + !data || + !Array.isArray(data.origins) || + !data.origins.every((o) => typeof o === "string") || + data.origins.length === 0 + ) { + return false; + } + + // Track unique labels (eTLD+1) to enforce the max labels limit + const labelsSeen = new Set(); + + for (const allowedOrigin of data.origins as string[]) { + try { + const url = new URL(allowedOrigin); + const hostname = url.hostname; + if (!hostname) { + continue; + } + + const parsed = parse(hostname, { allowPrivateDomains: true }); + if (!parsed.domain || !parsed.publicSuffix) { + continue; + } + + // Extract the label (the part before the public suffix) + const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1); + + if (!label) { + continue; + } + + // Skip if we've already seen max labels and this is a new one + if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) { + continue; + } + + // Check for exact origin match + if (origin === allowedOrigin) { + return true; + } + + // Track the label if we haven't hit the limit + if (labelsSeen.size < ROR_MAX_LABELS) { + labelsSeen.add(label); + } + } catch { + // Invalid URL, skip this entry + continue; + } + } + + return false; + } catch { + // Network error, timeout, or other failure - fail closed + return false; + } +} + +/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * If that fails, checks if the origin is authorized via Related Origin Requests (ROR). + * + * The validation enforces the following rules: + * - The origin must use the HTTPS scheme + * - Both rpId and origin must be valid domain names (not IP addresses) + * - Both must have the same registrable domain (e.g., example.com) + * - The origin must either exactly match the rpId or be a subdomain of it + * - Single-label domains are rejected unless they are 'localhost' + * - Localhost is always valid when both rpId and origin are localhost + * + * @param rpId - The Relying Party identifier to validate + * @param origin - The origin URL to validate against (must start with https://) + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ +export async function isValidRpId( + rpId: string, + origin: string, + relatedOriginChecksEnabled: boolean, + fetchFn?: typeof fetch, +): Promise { + // Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin + const classicMatch = isValidRpIdInternal(rpId, origin); + + if (classicMatch) { + return true; + } + + if (!relatedOriginChecksEnabled) { + return false; + } + + // Fall back to Related Origin Requests (ROR) validation + return await isAllowedByRor(rpId, origin, fetchFn); } diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 4fd91fb19e6..7b298110040 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => { isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); + configService.getFeatureFlag$.mockReturnValue(of(false)); + client = new Fido2ClientService( authenticator, configService, @@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.createCredential(params, windowReference); @@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.assertCredential(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 2aa618e974d..8fabed450f8 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -3,6 +3,8 @@ import { firstValueFrom, Subscription } from "rxjs"; import { parse } from "tldts"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; @@ -62,6 +64,9 @@ export class Fido2ClientService< MAX: 600000, }, }; + protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.WebAuthnRelatedOrigins, + ); constructor( private authenticator: Fido2AuthenticatorService, @@ -142,7 +147,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rp.id, params.origin)) { + if ( + !(await isValidRpId( + params.rp.id, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`, ); @@ -281,7 +292,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rpId, params.origin)) { + if ( + !(await isValidRpId( + params.rpId, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`, ); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index d9dfa128028..b9bcaad8cea 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -47,6 +47,12 @@ export class Attachment extends Domain { if (this.key != null) { view.key = await this.decryptAttachmentKey(decryptionKey); view.encryptedKey = this.key; // Keep the encrypted key for the view + + // When the attachment key couldn't be decrypted, mark a decryption error + // The file won't be able to be downloaded in these cases + if (!view.key) { + view.hasDecryptionError = true; + } } return view; diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index ef4a9ed8b27..6eaa943fba0 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; -import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { DECRYPT_ERROR, EncString } from "../../../key-management/crypto/models/enc-string"; import { View } from "../../../models/view/view"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Attachment } from "../domain/attachment"; @@ -18,6 +18,7 @@ export class AttachmentView implements View { * The SDK returns an encrypted key for the attachment. */ encryptedKey: EncString | undefined; + private _hasDecryptionError?: boolean; constructor(a?: Attachment) { if (!a) { @@ -41,6 +42,14 @@ export class AttachmentView implements View { return 0; } + get hasDecryptionError(): boolean { + return this._hasDecryptionError || this.fileName === DECRYPT_ERROR; + } + + set hasDecryptionError(value: boolean) { + this._hasDecryptionError = value; + } + static fromJSON(obj: Partial>): AttachmentView { const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key); @@ -76,7 +85,10 @@ export class AttachmentView implements View { /** * Converts the SDK AttachmentView to a AttachmentView. */ - static fromSdkAttachmentView(obj: SdkAttachmentView): AttachmentView | undefined { + static fromSdkAttachmentView( + obj: SdkAttachmentView, + failure = false, + ): AttachmentView | undefined { if (!obj) { return undefined; } @@ -90,6 +102,7 @@ export class AttachmentView implements View { // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined; view.encryptedKey = obj.key ? new EncString(obj.key) : undefined; + view._hasDecryptionError = failure; return view; } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 0909d0bda80..1e0cce8d72e 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -280,6 +280,17 @@ export class CipherView implements View, InitializerMetadata { return undefined; } + const attachments = obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + + if (obj.attachmentDecryptionFailures?.length) { + obj.attachmentDecryptionFailures.forEach((attachment) => { + const attachmentView = AttachmentView.fromSdkAttachmentView(attachment, true); + if (attachmentView) { + attachments.push(attachmentView); + } + }); + } + const cipherView = new CipherView(); cipherView.id = uuidAsString(obj.id); cipherView.organizationId = uuidAsString(obj.organizationId); @@ -295,8 +306,7 @@ export class CipherView implements View, InitializerMetadata { cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; cipherView.localData = fromSdkLocalData(obj.localData); - cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + cipherView.attachments = attachments; cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? []; cipherView.passwordHistory = obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? []; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 6373a511724..06c6628f158 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1191,33 +1191,28 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, admin = false, ): Promise { - const encKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled(); + // The organization's symmetric key or the user's user key + const vaultKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherEncKey = - cipherKeyEncryptionEnabled && cipher.key != null - ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, encKey)) as UserKey) - : encKey; + const cipherKeyOrVaultKey = + cipher.key != null + ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, vaultKey)) as UserKey) + : vaultKey; - //if cipher key encryption is disabled but the item has an individual key, - //then we rollback to using the user key as the main key of encryption of the item - //in order to keep item and it's attachments with the same encryption level - if (cipher.key != null && !cipherKeyEncryptionEnabled) { - const model = await this.decrypt(cipher, userId); - await this.updateWithServer(model, userId); - } + const encFileName = await this.encryptService.encryptString(filename, cipherKeyOrVaultKey); - const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); - - const dataEncKey = await this.keyService.makeDataEncKey(cipherEncKey); - const encData = await this.encryptService.encryptFileData(new Uint8Array(data), dataEncKey[0]); + const attachmentKey = await this.keyService.makeDataEncKey(cipherKeyOrVaultKey); + const encData = await this.encryptService.encryptFileData( + new Uint8Array(data), + attachmentKey[0], + ); const response = await this.cipherFileUploadService.upload( cipher, encFileName, encData, admin, - dataEncKey, + attachmentKey, ); const cData = new CipherData(response, cipher.collectionIds); diff --git a/libs/components/src/berry/berry.component.html b/libs/components/src/berry/berry.component.html new file mode 100644 index 00000000000..2a05f534843 --- /dev/null +++ b/libs/components/src/berry/berry.component.html @@ -0,0 +1,3 @@ +@if (type() === "status" || content()) { + {{ content() }} +} diff --git a/libs/components/src/berry/berry.component.ts b/libs/components/src/berry/berry.component.ts new file mode 100644 index 00000000000..8e58b888f39 --- /dev/null +++ b/libs/components/src/berry/berry.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export type BerryVariant = + | "primary" + | "subtle" + | "success" + | "warning" + | "danger" + | "accentPrimary" + | "contrast"; + +/** + * The berry component is a compact visual indicator used to display short, + * supplemental status information about another element, + * like a navigation item, button, or icon button. + * They draw users’ attention to status changes or new notifications. + * + * > `NOTE:` The maximum displayed value is 999. If the value is over 999, a “+” character is appended to indicate more. + */ +@Component({ + selector: "bit-berry", + templateUrl: "berry.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BerryComponent { + protected readonly variant = input("primary"); + protected readonly value = input(); + protected readonly type = input<"status" | "count">("count"); + + protected readonly content = computed(() => { + const value = this.value(); + const type = this.type(); + + if (type === "status" || !value || value < 0) { + return undefined; + } + return value > 999 ? "999+" : `${value}`; + }); + + protected readonly textColor = computed(() => { + return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white"; + }); + + protected readonly padding = computed(() => { + return (this.value()?.toString().length ?? 0) > 2 ? "tw-px-1.5 tw-py-0.5" : ""; + }); + + protected readonly containerClasses = computed(() => { + const baseClasses = [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-align-middle", + "tw-text-xxs", + "tw-rounded-full", + ]; + + const typeClasses = { + status: ["tw-h-2", "tw-w-2"], + count: ["tw-h-4", "tw-min-w-4", this.padding()], + }; + + const variantClass = { + primary: "tw-bg-bg-brand", + subtle: "tw-bg-bg-contrast", + success: "tw-bg-bg-success", + warning: "tw-bg-bg-warning", + danger: "tw-bg-bg-danger", + accentPrimary: "tw-bg-fg-accent-primary-strong", + contrast: "tw-bg-bg-white", + }; + + return [ + ...baseClasses, + ...typeClasses[this.type()], + variantClass[this.variant()], + this.textColor(), + ].join(" "); + }); +} diff --git a/libs/components/src/berry/berry.mdx b/libs/components/src/berry/berry.mdx new file mode 100644 index 00000000000..b79ed35cac8 --- /dev/null +++ b/libs/components/src/berry/berry.mdx @@ -0,0 +1,48 @@ +import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks"; + +import * as stories from "./berry.stories"; + + + +```ts +import { BerryComponent } from "@bitwarden/components"; +``` + + +<Description /> + +<Primary /> +<Controls /> + +## Usage + +### Status + +- Use a status berry to indicate a new notification of a status change that is not related to a + specific count. + +<Canvas of={stories.statusType} /> + +### Count + +- Use a count berry with text to indicate item count information for multiple new notifications. + +<Canvas of={stories.countType} /> + +### All Variants + +<Canvas of={stories.AllVariants} /> + +## Count Behavior + +- Counts of **1-99**: Display in a compact circular shape +- Counts of **100-999**: Display in a pill shape with padding +- Counts **over 999**: Display as "999+" to prevent overflow + +## Accessibility + +- Use berries as **supplemental visual indicators** alongside descriptive text +- Ensure sufficient color contrast with surrounding elements +- For screen readers, provide appropriate labels on parent elements that describe the berry's + meaning +- Berries are decorative; important information should not rely solely on the berry color diff --git a/libs/components/src/berry/berry.stories.ts b/libs/components/src/berry/berry.stories.ts new file mode 100644 index 00000000000..0b71e7259d8 --- /dev/null +++ b/libs/components/src/berry/berry.stories.ts @@ -0,0 +1,167 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { BerryComponent } from "./berry.component"; + +export default { + title: "Component Library/Berry", + component: BerryComponent, + decorators: [ + moduleMetadata({ + imports: [BerryComponent], + }), + ], + args: { + type: "count", + variant: "primary", + value: 5, + }, + argTypes: { + type: { + control: "select", + options: ["status", "count"], + description: "The type of the berry, which determines its size and content", + table: { + category: "Inputs", + type: { summary: '"status" | "count"' }, + defaultValue: { summary: '"count"' }, + }, + }, + variant: { + control: "select", + options: ["primary", "subtle", "success", "warning", "danger", "accentPrimary", "contrast"], + description: "The visual style variant of the berry", + table: { + category: "Inputs", + type: { summary: "BerryVariant" }, + defaultValue: { summary: "primary" }, + }, + }, + value: { + control: "number", + description: + "Optional value to display for berries with type 'count'. Maximum displayed is 999, values above show '999+'. If undefined, a small small berry is shown. If 0 or negative, the berry is hidden.", + table: { + category: "Inputs", + type: { summary: "number | undefined" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Tailwind-Component-Library?node-id=38367-199458&p=f&m=dev", + }, + }, +} as Meta<BerryComponent>; + +type Story = StoryObj<BerryComponent>; + +export const Primary: Story = { + render: (args) => ({ + props: args, + template: `<bit-berry [type]="type" [variant]="variant" [value]="value"></bit-berry>`, + }), +}; + +export const statusType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [type]="'status'" variant="primary"></bit-berry> + <bit-berry [type]="'status'" variant="subtle"></bit-berry> + <bit-berry [type]="'status'" variant="success"></bit-berry> + <bit-berry [type]="'status'" variant="warning"></bit-berry> + <bit-berry [type]="'status'" variant="danger"></bit-berry> + <bit-berry [type]="'status'" variant="accentPrimary"></bit-berry> + <bit-berry [type]="'status'" variant="contrast"></bit-berry> + </div> + `, + }), +}; + +export const countType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [value]="5"></bit-berry> + <bit-berry [value]="50"></bit-berry> + <bit-berry [value]="500"></bit-berry> + <bit-berry [value]="5000"></bit-berry> + </div> + `, + }), +}; + +export const AllVariants: Story = { + render: () => ({ + template: ` + <div class="tw-flex tw-flex-col tw-gap-4"> + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Primary:</span> + <bit-berry type="status" variant="primary"></bit-berry> + <bit-berry variant="primary" [value]="5"></bit-berry> + <bit-berry variant="primary" [value]="50"></bit-berry> + <bit-berry variant="primary" [value]="500"></bit-berry> + <bit-berry variant="primary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Subtle:</span> + <bit-berry type="status"variant="subtle"></bit-berry> + <bit-berry variant="subtle" [value]="5"></bit-berry> + <bit-berry variant="subtle" [value]="50"></bit-berry> + <bit-berry variant="subtle" [value]="500"></bit-berry> + <bit-berry variant="subtle" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Success:</span> + <bit-berry type="status" variant="success"></bit-berry> + <bit-berry variant="success" [value]="5"></bit-berry> + <bit-berry variant="success" [value]="50"></bit-berry> + <bit-berry variant="success" [value]="500"></bit-berry> + <bit-berry variant="success" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Warning:</span> + <bit-berry type="status" variant="warning"></bit-berry> + <bit-berry variant="warning" [value]="5"></bit-berry> + <bit-berry variant="warning" [value]="50"></bit-berry> + <bit-berry variant="warning" [value]="500"></bit-berry> + <bit-berry variant="warning" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Danger:</span> + <bit-berry type="status" variant="danger"></bit-berry> + <bit-berry variant="danger" [value]="5"></bit-berry> + <bit-berry variant="danger" [value]="50"></bit-berry> + <bit-berry variant="danger" [value]="500"></bit-berry> + <bit-berry variant="danger" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Accent primary:</span> + <bit-berry type="status" variant="accentPrimary"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5"></bit-berry> + <bit-berry variant="accentPrimary" [value]="50"></bit-berry> + <bit-berry variant="accentPrimary" [value]="500"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark"> + <span class="tw-w-20 tw-text-fg-white">Contrast:</span> + <bit-berry type="status" variant="contrast"></bit-berry> + <bit-berry variant="contrast" [value]="5"></bit-berry> + <bit-berry variant="contrast" [value]="50"></bit-berry> + <bit-berry variant="contrast" [value]="500"></bit-berry> + <bit-berry variant="contrast" [value]="5000"></bit-berry> + </div> + </div> + `, + }), +}; diff --git a/libs/components/src/berry/index.ts b/libs/components/src/berry/index.ts new file mode 100644 index 00000000000..8f85908653e --- /dev/null +++ b/libs/components/src/berry/index.ts @@ -0,0 +1 @@ +export * from "./berry.component"; diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 63fbb69399d..c32ce176d27 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -45,7 +45,7 @@ const drawerSizeToWidth = { // 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: "bit-dialog", + selector: "bit-dialog, [bit-dialog]", templateUrl: "./dialog.component.html", host: { "[class]": "classes()", diff --git a/libs/components/src/dialog/dialog/dialog.mdx b/libs/components/src/dialog/dialog/dialog.mdx index 056e4ac79bc..33dce6a53e0 100644 --- a/libs/components/src/dialog/dialog/dialog.mdx +++ b/libs/components/src/dialog/dialog/dialog.mdx @@ -82,3 +82,12 @@ The `background` input can be set to `alt` to change the background color. This dialogs that contain multiple card sections. <Canvas of={stories.WithCards} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index 012bb77f2ac..9b96e529789 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -225,8 +225,7 @@ export const WithCards: Story = { ...args, }, template: /*html*/ ` - <form [formGroup]="formObj"> - <bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> + <form [formGroup]="formObj" bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> <ng-container bitDialogContent> <bit-section> <bit-section-header> @@ -270,7 +269,7 @@ export const WithCards: Story = { </bit-section> </ng-container> <ng-container bitDialogFooter> - <button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button> + <button type="submit" bitButton buttonType="primary" [disabled]="loading">Save</button> <button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button> <button type="button" @@ -281,8 +280,7 @@ export const WithCards: Story = { size="default" label="Delete"></button> </ng-container> - </bit-dialog> - </form> + </form> `, }), args: { diff --git a/libs/components/src/dialog/dialogs.mdx b/libs/components/src/dialog/dialogs.mdx index ee03dda6f57..2b8afb06783 100644 --- a/libs/components/src/dialog/dialogs.mdx +++ b/libs/components/src/dialog/dialogs.mdx @@ -25,6 +25,35 @@ interruptive if overused. For non-blocking, supplementary content, open dialogs as a [Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`). +### Closing Drawers on Navigation + +When using drawers, you may want to close them automatically when the user navigates to another page +to prevent the drawer from persisting across route changes. To implement this functionality: + +1. Store a reference to the dialog when opening it +2. Implement `OnDestroy` and close the dialog in `ngOnDestroy` + +```ts +import { Component, OnDestroy } from "@angular/core"; +import { DialogRef } from "@bitwarden/components"; + +export class MyComponent implements OnDestroy { + private myDialogRef: DialogRef; + + ngOnDestroy() { + this.myDialogRef?.close(); + } + + openDrawer() { + this.myDialogRef = this.dialogService.open(MyDialogComponent, { + // dialog options + }); + } +} +``` + +This ensures drawers are closed when the component is destroyed during navigation. + ## Placement Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to @@ -63,3 +92,12 @@ Once closed, focus should remain on the element which triggered the Dialog. **Note:** If a Simple Dialog is triggered from a main Dialog, be sure to make sure focus is moved to the Simple Dialog. + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html index 470f4846785..2e285495934 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html @@ -1,27 +1,25 @@ -<form [formGroup]="formGroup" [bitSubmit]="accept"> - <bit-simple-dialog> - <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> +<form [formGroup]="formGroup" [bitSubmit]="accept" bit-simple-dialog> + <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> - <span bitDialogTitle>{{ title }}</span> + <span bitDialogTitle>{{ title }}</span> - <div bitDialogContent>{{ content }}</div> + <div bitDialogContent>{{ content }}</div> - <ng-container bitDialogFooter> - <button type="submit" bitButton bitFormButton buttonType="primary"> - {{ acceptButtonText }} + <ng-container bitDialogFooter> + <button type="submit" bitButton bitFormButton buttonType="primary"> + {{ acceptButtonText }} + </button> + + @if (showCancelButton) { + <button + type="button" + bitButton + bitFormButton + buttonType="secondary" + (click)="dialogRef.close(false)" + > + {{ cancelButtonText }} </button> - - @if (showCancelButton) { - <button - type="button" - bitButton - bitFormButton - buttonType="secondary" - (click)="dialogRef.close(false)" - > - {{ cancelButtonText }} - </button> - } - </ng-container> - </bit-simple-dialog> + } + </ng-container> </form> diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts index cd44a79c271..804c654186c 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts @@ -12,7 +12,7 @@ export class IconDirective {} // 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: "bit-simple-dialog", + selector: "bit-simple-dialog, [bit-simple-dialog]", templateUrl: "./simple-dialog.component.html", animations: [fadeIn], imports: [DialogTitleContainerDirective, TypographyDirective], diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx index 1d7a3668719..0720715478b 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -49,3 +49,12 @@ Simple dialogs can support scrolling content if necessary, but typically with la content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs). <Canvas of={stories.ScrollingContent} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-simple-dialog` attribute directly to the `<form>` +element instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-simple-dialog>...</form> +``` diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts index 3a178892908..c67d52280b0 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts @@ -126,3 +126,21 @@ export const TextOverflow: Story = { `, }), }; + +export const WithForm: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <form bit-simple-dialog> + <span bitDialogTitle>Confirm Action</span> + <span bitDialogContent> + Are you sure you want to proceed with this action? This cannot be undone. + </span> + <ng-container bitDialogFooter> + <button type="submit" bitButton buttonType="primary">Confirm</button> + <button type="button" bitButton buttonType="secondary">Cancel</button> + </ng-container> + </form> + `, + }), +}; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d92e0770e49..d0bb8576095 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -7,6 +7,7 @@ export * from "./avatar"; export * from "./badge-list"; export * from "./badge"; export * from "./banner"; +export * from "./berry"; export * from "./breadcrumbs"; export * from "./button"; export * from "./callout"; diff --git a/libs/components/src/link/link.component.ts b/libs/components/src/link/link.component.ts index d826a4633a9..79cf55da637 100644 --- a/libs/components/src/link/link.component.ts +++ b/libs/components/src/link/link.component.ts @@ -58,13 +58,12 @@ const commonStyles = [ "[&.tw-test-hover_span]:tw-underline", "[&:hover_span]:tw-decoration-[.125em]", "[&.tw-test-hover_span]:tw-decoration-[.125em]", - "disabled:tw-no-underline", - "disabled:tw-cursor-not-allowed", - "disabled:!tw-text-fg-disabled", - "disabled:hover:!tw-text-fg-disabled", - "disabled:hover:tw-no-underline", "focus-visible:tw-outline-none", "focus-visible:before:tw-ring-border-focus", + "[&:focus-visible_span]:tw-underline", + "[&:focus-visible_span]:tw-decoration-[.125em]", + "[&.tw-test-focus-visible_span]:tw-underline", + "[&.tw-test-focus-visible_span]:tw-decoration-[.125em]", // Workaround for html button tag not being able to be set to `display: inline` // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. @@ -93,6 +92,7 @@ const commonStyles = [ "aria-disabled:!tw-text-fg-disabled", "aria-disabled:hover:!tw-text-fg-disabled", "aria-disabled:hover:tw-no-underline", + "[&[aria-disabled]:focus-visible_span]:!tw-no-underline", ]; @Component({ diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index d8220c39ff8..5de00fac34f 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -317,6 +317,7 @@ module.exports = { base: ["1rem", "150%"], sm: ["0.875rem", "150%"], xs: [".75rem", "150%"], + xxs: [".5rem", "150%"], }, container: { "@5xl": "1100px", diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index 46eded5e86d..ac1453a925c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -199,6 +199,7 @@ export class SendDetailsComponent implements OnInit { deletionDate: new Date(this.formattedDeletionDate), expirationDate: new Date(this.formattedDeletionDate), password: value.password, + authType: value.authType, emails: value.emails ? value.emails .split(",") diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 6aaaf033e0d..0e214abd72d 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html @@ -4,52 +4,76 @@ <ul aria-labelledby="attachments" class="tw-list-none tw-pl-0"> @for (attachment of attachments; track attachment.id) { <li> - <bit-item> - <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ - attachment.fileName - }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> - <i - *ngIf="attachment.key == null" - slot="default-trailing" - class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" - [appA11yTitle]="'fixEncryptionTooltip' | i18n" - ></i> - </bit-item-content> - - <ng-container slot="end"> - <bit-item-action> - @if (attachment.key != null) { - <app-download-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipher]="cipher()" - [attachment]="attachment" - ></app-download-attachment> - } @else { - <button - [bitAction]="fixOldAttachment(attachment)" - bitButton - buttonType="primary" - size="small" - type="button" - > - {{ "fixEncryption" | i18n }} - </button> + @if (!attachment.hasDecryptionError) { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="attachment.fileName"> + {{ attachment.fileName }} + </span> + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + @if (attachment.key == null) { + <i + slot="default-trailing" + class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" + [appA11yTitle]="'fixEncryptionTooltip' | i18n" + ></i> } - </bit-item-action> - @if (cipher().edit) { + </bit-item-content> + + <ng-container slot="end"> <bit-item-action> - <app-delete-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipherId]="cipher().id" - [attachment]="attachment" - (onDeletionSuccess)="removeAttachment(attachment)" - ></app-delete-attachment> + @if (attachment.key != null) { + <app-download-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipher]="cipher()" + [attachment]="attachment" + ></app-download-attachment> + } @else { + <button + [bitAction]="fixOldAttachment(attachment)" + bitButton + buttonType="primary" + size="small" + type="button" + > + {{ "fixEncryption" | i18n }} + </button> + } </bit-item-action> - } - </ng-container> - </bit-item> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } @else { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="'errorCannotDecrypt' | i18n"> + {{ "errorCannotDecrypt" | i18n }} + </span> + </bit-item-content> + + <ng-container slot="end"> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } </li> } </ul> diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 002ad019653..88ee1f9b599 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -173,7 +173,7 @@ describe("CipherAttachmentsComponent", () => { const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]')); expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName); - expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName); + expect(fileSize.nativeElement.textContent.trim()).toEqual(attachment.sizeName); }); describe("bitSubmit", () => { diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html index 0a46b83b086..c8110b9e863 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -5,8 +5,12 @@ <bit-item-group> <bit-item *ngFor="let attachment of cipher.attachments"> <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + <span data-testid="file-name" [title]="getAttachmentFileName(attachment)"> + {{ getAttachmentFileName(attachment) }} + </span> + @if (!attachment.hasDecryptionError) { + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + } </bit-item-content> <ng-container slot="end"> <bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6"> diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts index 4e324d8002e..3826d3a3ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts @@ -8,9 +8,11 @@ import { NEVER, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ItemModule, @@ -59,6 +61,7 @@ export class AttachmentsV2ViewComponent { private billingAccountProfileStateService: BillingAccountProfileStateService, private stateProvider: StateProvider, private accountService: AccountService, + private i18nService: I18nService, ) { this.subscribeToHasPremiumCheck(); this.subscribeToOrgKey(); @@ -89,4 +92,12 @@ export class AttachmentsV2ViewComponent { } }); } + + getAttachmentFileName(attachment: AttachmentView): string { + if (attachment.hasDecryptionError) { + return this.i18nService.t("errorCannotDecrypt"); + } + + return attachment.fileName ?? ""; + } } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index a46ce28fca8..05d5e9bc276 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -36,12 +36,11 @@ describe("DownloadAttachmentComponent", () => { .mockResolvedValue({ url: "https://www.downloadattachement.com" }); const download = jest.fn(); - const attachment = { - id: "222-3333-4444", - url: "https://www.attachment.com", - fileName: "attachment-filename", - size: "1234", - } as AttachmentView; + const attachment = new AttachmentView(); + attachment.id = "222-3333-4444"; + attachment.url = "https://www.attachment.com"; + attachment.fileName = "attachment-filename"; + attachment.size = "1234"; const cipherView = { id: "5555-444-3333", @@ -123,7 +122,12 @@ describe("DownloadAttachmentComponent", () => { }); it("hides download button when the attachment has decryption failure", () => { - const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR }; + const decryptFailureAttachment = new AttachmentView(); + decryptFailureAttachment.id = attachment.id; + decryptFailureAttachment.url = attachment.url; + decryptFailureAttachment.size = attachment.size; + decryptFailureAttachment.fileName = DECRYPT_ERROR; + fixture.componentRef.setInput("attachment", decryptFailureAttachment); fixture.detectChanges(); diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index 31ed609637c..bdca510c5aa 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -4,7 +4,6 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -46,9 +45,7 @@ export class DownloadAttachmentComponent { private cipherService: CipherService, ) {} - protected readonly isDecryptionFailure = computed( - () => this.attachment().fileName === DECRYPT_ERROR, - ); + protected readonly isDecryptionFailure = computed(() => this.attachment().hasDecryptionError); /** Download the attachment */ download = async () => { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 268f5b912d1..d816b69bc58 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -4,12 +4,13 @@ bitButton buttonType="primary" type="button" - [bitMenuTriggerFor]="addOptions" + [bitMenuTriggerFor]="isOnlyCollectionCreation() ? null : addOptions" + (click)="handleButtonClick()" id="newItemDropdown" - [appA11yTitle]="'new' | i18n" + [appA11yTitle]="getButtonLabel() | i18n" > <i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i> - {{ "new" | i18n }} + {{ getButtonLabel() | i18n }} </button> <bit-menu #addOptions aria-labelledby="newItemDropdown"> @for (item of cipherMenuItems$ | async; track item.type) { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 0a755a9cdb4..1a592809691 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, output } from "@angular/core"; -import { map, shareReplay } from "rxjs"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { combineLatest, map, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -38,10 +39,18 @@ export class NewCipherMenuComponent { /** * Returns an observable that emits the cipher menu items, filtered by the restricted types. */ - cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe( - map((restrictedTypes) => { + cipherMenuItems$ = combineLatest([ + this.restrictedItemTypesService.restricted$, + toObservable(this.canCreateCipher), + toObservable(this.canCreateSshKey), + ]).pipe( + map(([restrictedTypes, canCreateCipher, canCreateSshKey]) => { + // If user cannot create ciphers at all, return empty array + if (!canCreateCipher) { + return []; + } return CIPHER_MENU_ITEMS.filter((item) => { - if (!this.canCreateSshKey() && item.type === CipherType.SshKey) { + if (!canCreateSshKey && item.type === CipherType.SshKey) { return false; } return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type); @@ -49,4 +58,40 @@ export class NewCipherMenuComponent { }), shareReplay({ bufferSize: 1, refCount: true }), ); + + /** + * Returns the appropriate button label based on what can be created. + * If only collections can be created (no ciphers or folders), show "New Collection". + * Otherwise, show "New". + */ + protected getButtonLabel(): string { + const canCreateCipher = this.canCreateCipher(); + const canCreateFolder = this.canCreateFolder(); + const canCreateCollection = this.canCreateCollection(); + + // If only collections can be created, be specific + if (!canCreateCipher && !canCreateFolder && canCreateCollection) { + return "newCollection"; + } + + return "new"; + } + + /** + * Returns true if only collections can be created (no other options). + * When this is true, the button should directly create a collection instead of showing a dropdown. + */ + protected isOnlyCollectionCreation(): boolean { + return !this.canCreateCipher() && !this.canCreateFolder() && this.canCreateCollection(); + } + + /** + * Handles the button click. If only collections can be created, directly emit the collection event. + * Otherwise, the menu trigger will handle opening the dropdown. + */ + protected handleButtonClick(): void { + if (this.isOnlyCollectionCreation()) { + this.collectionAdded.emit(); + } + } } diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index f5da99cae61..b7d24681e64 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -938,4 +938,142 @@ describe("DefaultVaultItemsTransferService", () => { expect(transferInProgressValues).toEqual([false, true, false]); }); }); + + describe("enforcementInFlight", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + const defaultCollection = { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView; + + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockOrganizationService.organizations$.mockReturnValue(of([organization])); + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCollectionService.defaultUserCollection$.mockReturnValue(of(defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); + }); + + it("prevents re-entry when enforcement is already in flight", async () => { + // Create a dialog that resolves after a delay + const delayedSubject = new Subject<any>(); + const delayedDialog = { + closed: delayedSubject.asObservable(), + close: jest.fn(), + } as unknown as DialogRef<any>; + + mockDialogService.open.mockReturnValue(delayedDialog); + + // Start first call (won't complete immediately) + const firstCall = service.enforceOrganizationDataOwnership(userId); + + // Flush microtasks to allow first call to set enforcementInFlight + await Promise.resolve(); + + // Second call should return immediately without opening dialog + await service.enforceOrganizationDataOwnership(userId); + + // Verify re-entry was prevented - only the first call should proceed + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + expect(mockPolicyService.policiesByType$).toHaveBeenCalledTimes(1); + + // Clean up - resolve the first call's dialog + delayedSubject.next(TransferItemsDialogResult.Declined); + delayedSubject.complete(); + + // Mock the leave dialog + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(LeaveConfirmationDialogResult.Confirmed), + ); + + await firstCall; + }); + + it("allows subsequent calls after user declines and leaves", async () => { + // First call: user declines and confirms leaving + mockDialogService.open + .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) + .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + + // Second call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after successful transfer", async () => { + // First call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call should be allowed (though no migration needed after first transfer) + // Set up scenario where migration is needed again + mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-2" } as CipherView])); + + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after transfer fails with error", async () => { + // First call: transfer fails + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed")); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call: user accepts transfer successfully + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index 3e65d3157f5..8b1c24c8ca2 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -62,6 +62,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi transferInProgress$ = this._transferInProgressSubject.asObservable(); + /** + * Only a single enforcement should be allowed to run at a time to prevent multiple dialogs + * or multiple simultaneous transfers. + */ + private enforcementInFlight: boolean = false; + private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> { return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe( map( @@ -142,7 +148,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi FeatureFlag.MigrateMyVaultToMyItems, ); - if (!featureEnabled) { + if (!featureEnabled || this.enforcementInFlight) { return; } @@ -160,6 +166,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi return; } + this.enforcementInFlight = true; + const userAcceptedTransfer = await this.promptUserForTransfer( migrationInfo.enforcingOrganization.name, ); @@ -179,6 +187,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi ); // Sync to reflect organization removal await this.syncService.fullSync(true); + this.enforcementInFlight = false; return; } @@ -208,6 +217,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi variant: "error", message: this.i18nService.t("errorOccurred"), }); + } finally { + this.enforcementInFlight = false; } } diff --git a/package-lock.json b/package-lock.json index 8f6a527627f..dbdcd6d083d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -63,7 +63,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -179,7 +179,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "webpack-node-externals": "3.0.0" @@ -218,63 +218,18 @@ "multer": "2.0.2", "node-fetch": "2.7.0", "node-forge": "1.3.2", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" }, "bin": { "bw": "build/bw.js" } }, - "apps/cli/node_modules/define-lazy-prop": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/is-docker": { - "version": "2.2.1", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/cli/node_modules/is-wsl": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/open": { - "version": "8.4.2", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "apps/desktop": { "name": "@bitwarden/desktop", "version": "2026.2.0", @@ -18920,63 +18875,6 @@ "node": ">=12.0.0" } }, - "node_modules/better-opn/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/better-opn/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -19384,6 +19282,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -21648,6 +21547,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -21664,6 +21564,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -21732,6 +21633,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -26848,6 +26750,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -26936,22 +26839,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -27281,6 +27173,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -33968,16 +33861,6 @@ } } }, - "node_modules/nx/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -34001,35 +33884,6 @@ "node": ">= 4" } }, - "node_modules/nx/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -34037,24 +33891,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nx/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nx/node_modules/ora": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", @@ -34629,41 +34465,58 @@ } }, "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open/node_modules/wsl-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz", - "integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==", + "node_modules/open/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "engines": { + "node": ">=8" + } + }, + "node_modules/open/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">=20" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -36653,18 +36506,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -38066,6 +37907,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -40910,9 +40752,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -41164,21 +41006,21 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "license": "MIT" }, "node_modules/tmp": { @@ -43334,9 +43176,9 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "dependencies": { @@ -43348,10 +43190,10 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -43362,7 +43204,7 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, @@ -44154,6 +43996,13 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/package.json b/package.json index 751c67afcd1..bc1553c4622 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "webpack-node-externals": "3.0.0" @@ -193,7 +193,7 @@ "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -201,7 +201,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4",