diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index b31b22b926e..73b765f207a 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -71,6 +71,7 @@ jobs: - name: Get Node Version id: retrieve-node-version + working-directory: ./ run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} @@ -104,7 +105,7 @@ jobs: env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - _WIN_PKG_FETCH_VERSION: 20.11.1 + _WIN_PKG_FETCH_VERSION: 22.15.1 _WIN_PKG_VERSION: 3.5 permissions: contents: read @@ -283,7 +284,7 @@ jobs: env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - _WIN_PKG_FETCH_VERSION: 20.11.1 + _WIN_PKG_FETCH_VERSION: 22.15.1 _WIN_PKG_VERSION: 3.5 steps: - name: Check out repo diff --git a/apps/browser/package.json b/apps/browser/package.json index 8cf643135a2..cb0de28df5b 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.7.0", + "version": "2025.7.1", "scripts": { "build": "npm run build:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ad933c24875..f8dde376b35 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5573,5 +5573,11 @@ "wasmNotSupported": { "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", "description": "'WebAssembly' is a technical term and should not be translated." + }, + "showMore": { + "message": "Show more" + }, + "showLess": { + "message": "Show less" } } diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index f2152b44862..571d9fbaf5f 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -1,5 +1,6 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; +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"; @@ -17,7 +18,7 @@ interface NotificationQueueMessage { interface AddChangePasswordQueueMessage extends NotificationQueueMessage { type: "change"; - cipherId: string; + cipherId: CipherView["id"]; newPassword: string; } diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index d446e18b480..71452ec975a 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -28,19 +28,12 @@ export type ModifyLoginCipherFormData = { newPassword: string; }; -export type ModifyLoginCipherFormDataForTab = Map< - chrome.tabs.Tab["id"], - { uri: string; username: string; password: string; newPassword: string } ->; +export type ModifyLoginCipherFormDataForTab = Map; export type OverlayNotificationsExtensionMessage = { command: string; - uri?: string; - username?: string; - password?: string; - newPassword?: string; details?: AutofillPageDetails; -}; +} & ModifyLoginCipherFormData; type OverlayNotificationsMessageParams = { message: OverlayNotificationsExtensionMessage }; type OverlayNotificationSenderParams = { sender: chrome.runtime.MessageSender }; diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index 00114330bc4..cf317de4fd2 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,12 +1,9 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; import AutofillField from "../models/autofill-field"; @@ -27,9 +24,6 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou describe("OverlayNotificationsBackground", () => { let logService: MockProxy; let notificationBackground: NotificationBackground; - let taskService: TaskService; - let accountService: AccountService; - let cipherService: CipherService; let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let getEnableAddedLoginPromptSpy: jest.SpyInstance; let overlayNotificationsBackground: OverlayNotificationsBackground; @@ -38,9 +32,6 @@ describe("OverlayNotificationsBackground", () => { jest.useFakeTimers(); logService = mock(); notificationBackground = mock(); - taskService = mock(); - accountService = mock(); - cipherService = mock(); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .mockResolvedValue(true); @@ -50,9 +41,6 @@ describe("OverlayNotificationsBackground", () => { overlayNotificationsBackground = new OverlayNotificationsBackground( logService, notificationBackground, - taskService, - accountService, - cipherService, ); await overlayNotificationsBackground.init(); }); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 1d7f2b1f9d8..e7126a57e9f 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -2,11 +2,8 @@ // @ts-strict-ignore import { Subject, switchMap, timer } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar"; @@ -31,6 +28,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private notificationFallbackTimeout: number | NodeJS.Timeout | null; private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { + generatedPasswordFilled: ({ message, sender }) => + this.storeModifiedLoginFormData(message, sender), formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender), collectPageDetailsResponse: ({ message, sender }) => this.handleCollectPageDetailsResponse(message, sender), @@ -39,9 +38,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg constructor( private logService: LogService, private notificationBackground: NotificationBackground, - private taskService: TaskService, - private accountService: AccountService, - private cipherService: CipherService, ) {} /** @@ -442,7 +438,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg } } - this.clearCompletedWebRequest(requestId, tab); + this.clearCompletedWebRequest(requestId, tab.id); return results.join(" "); }; @@ -482,11 +478,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private clearCompletedWebRequest = ( requestId: chrome.webRequest.ResourceRequest["requestId"], - tab: chrome.tabs.Tab, + tabId: chrome.tabs.Tab["id"], ) => { this.activeFormSubmissionRequests.delete(requestId); - this.modifyLoginCipherFormData.delete(tab.id); - this.websiteOriginsWithFields.delete(tab.id); + this.modifyLoginCipherFormData.delete(tabId); + this.websiteOriginsWithFields.delete(tabId); this.setupWebRequestsListeners(); }; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 92b2135c973..8bee4a4675d 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -49,7 +49,6 @@ import { MAX_SUB_FRAME_DEPTH, RedirectFocusDirection, } from "../enums/autofill-overlay.enum"; -import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service"; import { AutofillService } from "../services/abstractions/autofill.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { @@ -71,6 +70,7 @@ import { triggerWebRequestOnCompletedEvent, } from "../spec/testing-utils"; +import { ModifyLoginCipherFormData } from "./abstractions/overlay-notifications.background"; import { FocusedFieldData, InlineMenuPosition, @@ -2076,7 +2076,7 @@ describe("OverlayBackground", () => { const tab = createChromeTabMock({ id: 2 }); const sender = mock({ tab, frameId: 100 }); let focusedFieldData: FocusedFieldData; - let formData: InlineMenuFormFieldData; + let formData: ModifyLoginCipherFormData; beforeEach(async () => { await initOverlayElementPorts(); @@ -3651,6 +3651,18 @@ describe("OverlayBackground", () => { }); }); + it("sends a message to the tab to store modify login change when a password is generated", async () => { + jest.useFakeTimers(); + + sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); + + await flushPromises(); + jest.advanceTimersByTime(400); + await flushPromises(); + + expect(tabsSendMessageSpy.mock.lastCall[1].command).toBe("generatedPasswordModifyLogin"); + }); + it("filters the page details to only include the new password fields before filling", async () => { sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); await flushPromises(); @@ -3663,31 +3675,6 @@ describe("OverlayBackground", () => { allowTotpAutofill: false, }); }); - - it("opens the inline menu for fields that fill a generated password", async () => { - jest.useFakeTimers(); - const formData = { - uri: "https://example.com", - username: "username", - password: "password", - newPassword: "newPassword", - }; - tabsSendMessageSpy.mockImplementation((_tab, message) => { - if (message.command === "getInlineMenuFormFieldData") { - return Promise.resolve(formData); - } - - return Promise.resolve(); - }); - const openInlineMenuSpy = jest.spyOn(overlayBackground as any, "openInlineMenu"); - - sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey }); - await flushPromises(); - jest.advanceTimersByTime(400); - await flushPromises(); - - expect(openInlineMenuSpy).toHaveBeenCalled(); - }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index f55b5c8cc3d..4027689f014 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -69,7 +69,6 @@ import { MAX_SUB_FRAME_DEPTH, } from "../enums/autofill-overlay.enum"; import AutofillField from "../models/autofill-field"; -import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service"; import { AutofillService, PageDetail } from "../services/abstractions/autofill.service"; import { InlineMenuFieldQualificationService } from "../services/abstractions/inline-menu-field-qualifications.service"; import { @@ -82,6 +81,7 @@ import { } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; +import { ModifyLoginCipherFormData } from "./abstractions/overlay-notifications.background"; import { BuildCipherDataParams, CloseInlineMenuMessage, @@ -1813,7 +1813,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Triggers a fill of the generated password into the current tab. Will trigger - * a focus of the last focused field after filling the password. + * a focus of the last focused field after filling the password. * * @param port - The port of the sender */ @@ -1857,10 +1857,16 @@ export class OverlayBackground implements OverlayBackgroundInterface { }); globalThis.setTimeout(async () => { - if (await this.shouldShowSaveLoginInlineMenuList(port.sender.tab)) { - await this.openInlineMenu(port.sender, true); - } - }, 300); + await BrowserApi.tabSendMessage( + port.sender.tab, + { + command: "generatedPasswordModifyLogin", + }, + { + frameId: this.focusedFieldData.frameId || 0, + }, + ); + }, 150); } /** @@ -1891,7 +1897,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { * * @param tab - The tab to get the form field data from */ - private async getInlineMenuFormFieldData(tab: chrome.tabs.Tab): Promise { + private async getInlineMenuFormFieldData( + tab: chrome.tabs.Tab, + ): Promise { return await BrowserApi.tabSendMessage( tab, { diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index b6fc6c3392e..5a71e3bd8da 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -35,7 +35,7 @@ class AutofillInit implements AutofillInitInterface { * @param domElementVisibilityService - Used to check if an element is viewable. * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. * @param autofillInlineMenuContentService - The inline menu content service, potentially undefined. - * @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined. + * @param overlayNotificationsContentService - The overlay server notifications content service, potentially undefined. */ constructor( domQueryService: DomQueryService, diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index fac6221790f..ddacb547908 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -1,3 +1,4 @@ +import { ModifyLoginCipherFormData } from "../../background/abstractions/overlay-notifications.background"; import { SubFrameOffsetData } from "../../background/abstractions/overlay.background"; import { AutofillExtensionMessageParam } from "../../content/abstractions/autofill-init"; import AutofillField from "../../models/autofill-field"; @@ -8,13 +9,6 @@ export type SubFrameDataFromWindowMessage = SubFrameOffsetData & { subFrameDepth: number; }; -export type InlineMenuFormFieldData = { - uri: string; - username: string; - password: string; - newPassword: string; -}; - export type AutofillOverlayContentExtensionMessageHandlers = { [key: string]: CallableFunction; addNewVaultItemFromOverlay: ({ message }: AutofillExtensionMessageParam) => void; @@ -32,7 +26,7 @@ export type AutofillOverlayContentExtensionMessageHandlers = { destroyAutofillInlineMenuListeners: () => void; getInlineMenuFormFieldData: ({ message, - }: AutofillExtensionMessageParam) => Promise; + }: AutofillExtensionMessageParam) => Promise; }; export interface AutofillOverlayContentService { 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 730b002953b..96b05b81c96 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 @@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { EVENTS } from "@bitwarden/common/autofill/constants"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { ModifyLoginCipherFormData } from "../background/abstractions/overlay-notifications.background"; import AutofillInit from "../content/autofill-init"; import { AutofillOverlayElement, @@ -1750,6 +1751,29 @@ describe("AutofillOverlayContentService", () => { }); describe("extension onMessage handlers", () => { + describe("generatedPasswordModifyLogin", () => { + it("relays a message regarding password generation to store modified login data", async () => { + const formFieldData: ModifyLoginCipherFormData = { + newPassword: "newPassword", + password: "password", + uri: "http://localhost/", + username: "username", + }; + + jest + .spyOn(autofillOverlayContentService as any, "getFormFieldData") + .mockResolvedValue(formFieldData); + + sendMockExtensionMessage({ + command: "generatedPasswordModifyLogin", + }); + await flushPromises(); + + const resolvedValue = await sendExtensionMessageSpy.mock.calls[0][1]; + expect(resolvedValue).toEqual(formFieldData); + }); + }); + describe("addNewVaultItemFromOverlay message handler", () => { it("skips sending the message if the overlay list is not visible", async () => { jest 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 1a972e0eaa0..4db00901759 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -12,6 +12,7 @@ import { } from "@bitwarden/common/autofill/constants"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { ModifyLoginCipherFormData } from "../background/abstractions/overlay-notifications.background"; import { FocusedFieldData, NewCardCipherData, @@ -48,7 +49,6 @@ import { import { AutofillOverlayContentExtensionMessageHandlers, AutofillOverlayContentService as AutofillOverlayContentServiceInterface, - InlineMenuFormFieldData, SubFrameDataFromWindowMessage, } from "./abstractions/autofill-overlay-content.service"; import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; @@ -95,6 +95,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ destroyAutofillInlineMenuListeners: () => this.destroy(), getInlineMenuFormFieldData: ({ message }) => this.handleGetInlineMenuFormFieldDataMessage(message), + generatedPasswordModifyLogin: () => this.sendGeneratedPasswordModifyLogin(), }; private readonly loginFieldQualifiers: Record = { [AutofillFieldQualifier.username]: this.inlineMenuFieldQualificationService.isUsernameField, @@ -235,6 +236,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ }); } + /** + * On password generation, send form field data i.e. modified login data + */ + sendGeneratedPasswordModifyLogin = async () => { + await this.sendExtensionMessage("generatedPasswordFilled", this.getFormFieldData()); + }; + /** * Formats any found user filled fields for a login cipher and sends a message * to the background script to add a new cipher. @@ -637,7 +645,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ /** * Returns the form field data used for add login and change password notifications. */ - private getFormFieldData = (): InlineMenuFormFieldData => { + private getFormFieldData = (): ModifyLoginCipherFormData => { return { uri: globalThis.document.URL, username: this.userFilledFields["username"]?.value || "", diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index aaa23a140db..81a869917a6 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -7,7 +7,7 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; const IdleInterval = 60 * 5; // 5 minutes diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 77494826095..f413f0788e5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -121,7 +121,7 @@ import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { DefaultServerNotificationsService, @@ -129,9 +129,7 @@ import { UnsupportedWebPushConnectionService, WebPushNotificationsApiService, WorkerWebPushConnectionService, -} from "@bitwarden/common/platform/notifications/internal"; -import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service"; -import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/notifications/unsupported-system-notifications.service"; +} from "@bitwarden/common/platform/server-notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -171,6 +169,8 @@ import { WindowStorageService } from "@bitwarden/common/platform/storage/window- import { SyncService } from "@bitwarden/common/platform/sync"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/"; +import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -1294,9 +1294,6 @@ export default class MainBackground { this.overlayNotificationsBackground = new OverlayNotificationsBackground( this.logService, this.notificationBackground, - this.taskService, - this.accountService, - this.cipherService, ); this.autoSubmitLoginBackground = new AutoSubmitLoginBackground( @@ -1411,6 +1408,7 @@ export default class MainBackground { this.badgeService = new BadgeService( this.stateProvider, new DefaultBadgeBrowserApi(this.platformUtilsService), + this.logService, ); this.authStatusBadgeUpdaterService = new AuthStatusBadgeUpdaterService( this.badgeService, diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index d6cf535b6d2..9322d9167ff 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.7.0", + "version": "2025.7.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 615ae9115b4..3a1537dc4aa 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.7.0", + "version": "2025.7.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/badge/badge-browser-api.ts b/apps/browser/src/platform/badge/badge-browser-api.ts index 9febaf8d39c..097c6109743 100644 --- a/apps/browser/src/platform/badge/badge-browser-api.ts +++ b/apps/browser/src/platform/badge/badge-browser-api.ts @@ -1,7 +1,10 @@ +import { map, Observable } from "rxjs"; + import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { BrowserApi } from "../browser/browser-api"; +import { fromChromeEvent } from "../browser/from-chrome-event"; import { BadgeIcon, IconPaths } from "./icon"; @@ -13,6 +16,8 @@ export interface RawBadgeState { } export interface BadgeBrowserApi { + activeTab$: Observable; + setState(state: RawBadgeState, tabId?: number): Promise; getTabs(): Promise; } @@ -21,6 +26,10 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { private badgeAction = BrowserApi.getBrowserAction(); private sidebarAction = BrowserApi.getSidebarAction(self); + activeTab$ = fromChromeEvent(chrome.tabs.onActivated).pipe( + map(([tabActiveInfo]) => tabActiveInfo), + ); + constructor(private platformUtilsService: PlatformUtilsService) {} async setState(state: RawBadgeState, tabId?: number): Promise { diff --git a/apps/browser/src/platform/badge/badge.service.spec.ts b/apps/browser/src/platform/badge/badge.service.spec.ts index 2a7ba2ce392..52be2afa71b 100644 --- a/apps/browser/src/platform/badge/badge.service.spec.ts +++ b/apps/browser/src/platform/badge/badge.service.spec.ts @@ -1,5 +1,7 @@ +import { mock, MockProxy } from "jest-mock-extended"; import { Subscription } from "rxjs"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; import { RawBadgeState } from "./badge-browser-api"; @@ -13,6 +15,7 @@ import { MockBadgeBrowserApi } from "./test/mock-badge-browser-api"; describe("BadgeService", () => { let badgeApi: MockBadgeBrowserApi; let stateProvider: FakeStateProvider; + let logService!: MockProxy; let badgeService!: BadgeService; let badgeServiceSubscription: Subscription; @@ -20,8 +23,9 @@ describe("BadgeService", () => { beforeEach(() => { badgeApi = new MockBadgeBrowserApi(); stateProvider = new FakeStateProvider(new FakeAccountService({})); + logService = mock(); - badgeService = new BadgeService(stateProvider, badgeApi); + badgeService = new BadgeService(stateProvider, badgeApi, logService); }); afterEach(() => { @@ -34,14 +38,10 @@ describe("BadgeService", () => { describe("given a single tab is open", () => { beforeEach(() => { badgeApi.tabs = [1]; + badgeApi.setActiveTab(tabId); badgeServiceSubscription = badgeService.startListening(); }); - // This relies on the state provider to auto-emit - it("sets default values on startup", async () => { - expect(badgeApi.generalState).toEqual(DefaultBadgeState); - }); - it("sets provided state when no other state has been set", async () => { const state: BadgeState = { text: "text", @@ -52,7 +52,6 @@ describe("BadgeService", () => { await badgeService.setState("state-name", BadgeStatePriority.Default, state); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(state); expect(badgeApi.specificStates[tabId]).toEqual(state); }); @@ -63,7 +62,6 @@ describe("BadgeService", () => { await badgeService.setState("state-name", BadgeStatePriority.Default, state); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); @@ -82,7 +80,6 @@ describe("BadgeService", () => { backgroundColor: "#fff", icon: BadgeIcon.Locked, }; - expect(badgeApi.generalState).toEqual(expectedState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -105,7 +102,6 @@ describe("BadgeService", () => { backgroundColor: "#aaa", icon: BadgeIcon.Locked, }; - expect(badgeApi.generalState).toEqual(expectedState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -126,7 +122,6 @@ describe("BadgeService", () => { backgroundColor: "#fff", icon: BadgeIcon.Locked, }; - expect(badgeApi.generalState).toEqual(expectedState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -147,7 +142,6 @@ describe("BadgeService", () => { await badgeService.clearState("state-3"); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); @@ -167,7 +161,6 @@ describe("BadgeService", () => { backgroundColor: "#fff", icon: DefaultBadgeState.icon, }; - expect(badgeApi.generalState).toEqual(expectedState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -190,26 +183,20 @@ describe("BadgeService", () => { backgroundColor: "#fff", icon: BadgeIcon.Unlocked, }; - expect(badgeApi.generalState).toEqual(expectedState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); }); describe("given multiple tabs are open", () => { + const tabId = 1; const tabIds = [1, 2, 3]; beforeEach(() => { badgeApi.tabs = tabIds; + badgeApi.setActiveTab(tabId); badgeServiceSubscription = badgeService.startListening(); }); - it("sets default values for each tab on startup", async () => { - expect(badgeApi.generalState).toEqual(DefaultBadgeState); - for (const tabId of tabIds) { - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - } - }); - it("sets state for each tab when no other state has been set", async () => { const state: BadgeState = { text: "text", @@ -220,11 +207,10 @@ describe("BadgeService", () => { await badgeService.setState("state-name", BadgeStatePriority.Default, state); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(state); expect(badgeApi.specificStates).toEqual({ 1: state, - 2: state, - 3: state, + 2: undefined, + 3: undefined, }); }); }); @@ -236,6 +222,7 @@ describe("BadgeService", () => { beforeEach(() => { badgeApi.tabs = [tabId]; + badgeApi.setActiveTab(tabId); badgeServiceSubscription = badgeService.startListening(); }); @@ -249,7 +236,6 @@ describe("BadgeService", () => { await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(state); }); @@ -260,7 +246,6 @@ describe("BadgeService", () => { await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); @@ -279,11 +264,6 @@ describe("BadgeService", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual({ - ...DefaultBadgeState, - text: "text", - icon: BadgeIcon.Locked, - }); expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", @@ -316,7 +296,6 @@ describe("BadgeService", () => { backgroundColor: "#fff", icon: BadgeIcon.Locked, }; - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -354,7 +333,6 @@ describe("BadgeService", () => { backgroundColor: "#aaa", icon: BadgeIcon.Locked, }; - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); @@ -377,11 +355,6 @@ describe("BadgeService", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual({ - text: "override", - backgroundColor: "#aaa", - icon: DefaultBadgeState.icon, - }); expect(badgeApi.specificStates[tabId]).toEqual({ text: "override", backgroundColor: "#aaa", @@ -411,7 +384,6 @@ describe("BadgeService", () => { await badgeService.clearState("state-2"); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", @@ -451,7 +423,6 @@ describe("BadgeService", () => { await badgeService.clearState("state-3"); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); @@ -476,7 +447,6 @@ describe("BadgeService", () => { ); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", @@ -513,7 +483,6 @@ describe("BadgeService", () => { ); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(DefaultBadgeState); expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", @@ -523,14 +492,16 @@ describe("BadgeService", () => { }); describe("given multiple tabs are open", () => { + const tabId = 1; const tabIds = [1, 2, 3]; beforeEach(() => { badgeApi.tabs = tabIds; + badgeApi.setActiveTab(tabId); badgeServiceSubscription = badgeService.startListening(); }); - it("sets tab-specific state for provided tab and general state for the others", async () => { + it("sets tab-specific state for provided tab", async () => { const generalState: BadgeState = { text: "general-text", backgroundColor: "general-color", @@ -550,11 +521,10 @@ describe("BadgeService", () => { ); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.generalState).toEqual(generalState); expect(badgeApi.specificStates).toEqual({ [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, - [tabIds[1]]: generalState, - [tabIds[2]]: generalState, + [tabIds[1]]: undefined, + [tabIds[2]]: undefined, }); }); }); diff --git a/apps/browser/src/platform/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts index d48150ac516..b3831530e8d 100644 --- a/apps/browser/src/platform/badge/badge.service.ts +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -1,15 +1,15 @@ import { - defer, + combineLatest, + concatMap, distinctUntilChanged, filter, map, - mergeMap, pairwise, startWith, Subscription, - switchMap, } from "rxjs"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BADGE_MEMORY, GlobalState, @@ -39,6 +39,7 @@ export class BadgeService { constructor( private stateProvider: StateProvider, private badgeApi: BadgeBrowserApi, + private logService: LogService, ) { this.states = this.stateProvider.getGlobal(BADGE_STATES); } @@ -48,52 +49,47 @@ export class BadgeService { * Without this the service will not be able to update the badge state. */ startListening(): Subscription { - const initialSetup$ = defer(async () => { - const openTabs = await this.badgeApi.getTabs(); - await this.badgeApi.setState(DefaultBadgeState); - for (const tabId of openTabs) { - await this.badgeApi.setState(DefaultBadgeState, tabId); - } - }); - - return initialSetup$ - .pipe( - switchMap(() => this.states.state$), + return combineLatest({ + states: this.states.state$.pipe( startWith({}), distinctUntilChanged(), map((states) => new Set(states ? Object.values(states) : [])), pairwise(), map(([previous, current]) => { const [removed, added] = difference(previous, current); - return { states: current, removed, added }; + return { all: current, removed, added }; }), filter(({ removed, added }) => removed.size > 0 || added.size > 0), - mergeMap(async ({ states, removed, added }) => { - const changed = [...removed, ...added]; - const changedTabIds = new Set( - changed.map((s) => s.tabId).filter((tabId) => tabId !== undefined), - ); - const onlyTabSpecificStatesChanged = changed.every((s) => s.tabId != undefined); - if (onlyTabSpecificStatesChanged) { - // If only tab-specific states changed then we only need to update those specific tabs. - for (const tabId of changedTabIds) { - const newState = this.calculateState(states, tabId); - await this.badgeApi.setState(newState, tabId); - } + ), + activeTab: this.badgeApi.activeTab$.pipe(startWith(undefined)), + }) + .pipe( + concatMap(async ({ states, activeTab }) => { + const changed = [...states.removed, ...states.added]; + + // If the active tab wasn't changed, we don't need to update the badge. + if (!changed.some((s) => s.tabId === activeTab?.tabId || s.tabId === undefined)) { return; } - // If there are any general states that changed then we need to update all tabs. - const openTabs = await this.badgeApi.getTabs(); - const generalState = this.calculateState(states); - await this.badgeApi.setState(generalState); - for (const tabId of openTabs) { - const newState = this.calculateState(states, tabId); - await this.badgeApi.setState(newState, tabId); + try { + const state = this.calculateState(states.all, activeTab?.tabId); + await this.badgeApi.setState(state, activeTab?.tabId); + } catch (error) { + // This usually happens when the user opens a popout because of how the browser treats it + // as a tab in the same window but then won't let you set the badge state for it. + this.logService.warning("Failed to set badge state", error); } }), ) - .subscribe(); + .subscribe({ + error: (err: unknown) => { + this.logService.error( + "Fatal error in badge service observable, badge will fail to update", + err, + ); + }, + }); } /** diff --git a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts index 19bde1e1fd8..4f91420b273 100644 --- a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts +++ b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts @@ -1,10 +1,22 @@ +import { BehaviorSubject } from "rxjs"; + import { BadgeBrowserApi, RawBadgeState } from "../badge-browser-api"; export class MockBadgeBrowserApi implements BadgeBrowserApi { + private _activeTab$ = new BehaviorSubject(undefined); + activeTab$ = this._activeTab$.asObservable(); + specificStates: Record = {}; generalState?: RawBadgeState; tabs: number[] = []; + setActiveTab(tabId: number) { + this._activeTab$.next({ + tabId, + windowId: 1, + }); + } + setState(state: RawBadgeState, tabId?: number): Promise { if (tabId !== undefined) { this.specificStates[tabId] = state; diff --git a/apps/browser/src/platform/notifications/foreground-server-notifications.service.ts b/apps/browser/src/platform/notifications/foreground-server-notifications.service.ts index 02fd092339f..6ab164d9c63 100644 --- a/apps/browser/src/platform/notifications/foreground-server-notifications.service.ts +++ b/apps/browser/src/platform/notifications/foreground-server-notifications.service.ts @@ -2,10 +2,10 @@ import { Observable, Subscription } from "rxjs"; import { NotificationResponse } from "@bitwarden/common/models/response/notification.response"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { UserId } from "@bitwarden/common/types/guid"; -// Eventually if we want to support listening to notifications from browser foreground we +// Eventually if we want to support listening to server notifications from browser foreground we // will only ever create a single SignalR connection, likely messaging to the background to reuse its connection. export class ForegroundServerNotificationsService implements ServerNotificationsService { notifications$: Observable; diff --git a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts index a9a0e8412ea..6f6bb9ed262 100644 --- a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts +++ b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts @@ -1,7 +1,5 @@ import { map, merge, Observable } from "rxjs"; -import { v4 as uuidv4 } from "uuid"; -import { DeviceType } from "@bitwarden/common/enums"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { @@ -10,7 +8,7 @@ import { SystemNotificationCreateInfo, SystemNotificationEvent, SystemNotificationsService, -} from "@bitwarden/common/platform/notifications/system-notifications.service"; +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; import { fromChromeEvent } from "../browser/from-chrome-event"; @@ -37,60 +35,24 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ ); } - async create(createInfo: SystemNotificationCreateInfo): Promise { - try { - const notificationId = createInfo.id || uuidv4(); - const deviceType = this.platformUtilsService.getDevice(); - - const notificationOptions: chrome.notifications.NotificationOptions = { - iconUrl: "https://avatars.githubusercontent.com/u/15990069?s=200", + async create(createInfo: SystemNotificationCreateInfo): Promise { + return new Promise((resolve) => { + const options: chrome.notifications.NotificationOptions = { + iconUrl: chrome.runtime.getURL("images/icon128.png"), message: createInfo.body, type: "basic", title: createInfo.title, - buttons: createInfo.buttons.map((value) => { - return { title: value.title }; - }), + buttons: createInfo.buttons.map((value) => ({ title: value.title })), }; - switch (deviceType) { - case DeviceType.FirefoxExtension: - // Firefox does not support buttons in notifications - delete notificationOptions.buttons; - break; - default: - break; + if (createInfo.id != null) { + chrome.notifications.create(createInfo.id, options, (notificationId) => + resolve(notificationId), + ); + } else { + chrome.notifications.create(options, (notificationId) => resolve(notificationId)); } - - chrome.notifications.create(notificationId, notificationOptions); - - // eslint-disable-next-line no-restricted-syntax - chrome.notifications.onButtonClicked.addListener( - (notificationId: string, buttonIndex: number) => { - this.notificationClicked$.subscribe({ - next: () => ({ - id: notificationId, - buttonIdentifier: buttonIndex, - }), - }); - }, - ); - - // eslint-disable-next-line no-restricted-syntax - chrome.notifications.onClicked.addListener((notificationId: string) => { - this.notificationClicked$.subscribe({ - next: () => ({ - id: notificationId, - buttonIdentifier: ButtonLocation.NotificationButton, - }), - }); - }); - - return notificationId; - } catch (e) { - this.logService.error( - `Failed to create notification on ${this.platformUtilsService.getDevice()} with error: ${e}`, - ); - } + }); } async clear(clearInfo: SystemNotificationClearInfo): Promise { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a5979bdd233..1bb6790c987 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -96,9 +96,8 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { flagEnabled } from "@bitwarden/common/platform/misc/flags"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; -import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service"; import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; @@ -114,6 +113,7 @@ import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/imp import { PrimarySecondaryStorageService } from "@bitwarden/common/platform/storage/primary-secondary-storage.service"; import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { SyncService } from "@bitwarden/common/platform/sync"; +import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; diff --git a/apps/browser/src/safari/desktop.xcodeproj/project.pbxproj b/apps/browser/src/safari/desktop.xcodeproj/project.pbxproj index 7642e7d1859..05e6e8be978 100644 --- a/apps/browser/src/safari/desktop.xcodeproj/project.pbxproj +++ b/apps/browser/src/safari/desktop.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 03100CAF291891F4008E14EF /* encrypt-worker.js in Resources */ = {isa = PBXBuildFile; fileRef = 03100CAE291891F4008E14EF /* encrypt-worker.js */; }; 55BC93932CB4268A008CA4C6 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 55BC93922CB4268A008CA4C6 /* assets */; }; 55E0374D2577FA6B00979016 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E0374C2577FA6B00979016 /* AppDelegate.swift */; }; 55E037502577FA6B00979016 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 55E0374E2577FA6B00979016 /* Main.storyboard */; }; @@ -53,7 +52,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 03100CAE291891F4008E14EF /* encrypt-worker.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = "encrypt-worker.js"; path = "../../../build/encrypt-worker.js"; sourceTree = ""; }; 5508DD7926051B5900A85C58 /* libswiftAppKit.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftAppKit.tbd; path = usr/lib/swift/libswiftAppKit.tbd; sourceTree = SDKROOT; }; 55BC93922CB4268A008CA4C6 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = ../../../build/assets; sourceTree = ""; }; 55E037482577FA6B00979016 /* desktop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = desktop.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -155,7 +153,6 @@ isa = PBXGroup; children = ( 55BC93922CB4268A008CA4C6 /* assets */, - 03100CAE291891F4008E14EF /* encrypt-worker.js */, 55E037702577FA6F00979016 /* popup */, 55E037712577FA6F00979016 /* background.js */, 55E037722577FA6F00979016 /* images */, @@ -272,7 +269,6 @@ 55E037802577FA6F00979016 /* background.html in Resources */, 55E0377A2577FA6F00979016 /* background.js in Resources */, 55E037792577FA6F00979016 /* popup in Resources */, - 03100CAF291891F4008E14EF /* encrypt-worker.js in Resources */, 55BC93932CB4268A008CA4C6 /* assets in Resources */, 55E0377C2577FA6F00979016 /* notification in Resources */, 55E0377E2577FA6F00979016 /* vendor.js in Resources */, diff --git a/apps/cli/.nvmrc b/apps/cli/.nvmrc deleted file mode 100644 index 9a2a0e219c9..00000000000 --- a/apps/cli/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v20 diff --git a/apps/cli/package.json b/apps/cli/package.json index 0d3c151f012..a4ff56206e4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -90,9 +90,5 @@ "semver": "7.7.2", "tldts": "7.0.1", "zxcvbn": "4.4.2" - }, - "engines": { - "node": "~20", - "npm": "~10" } } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 6afda4e9ddf..8c74624414b 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -61,7 +61,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index dad7739bc5a..0738aaba295 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -13,7 +13,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync"; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8b30bd85ec9..72d40ed750f 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3620,7 +3620,7 @@ }, "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", - "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." + "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", @@ -4066,6 +4066,12 @@ } } }, + "showMore": { + "message": "Show more" + }, + "showLess": { + "message": "Show less" + }, "enableAutotype": { "message": "Enable Autotype" }, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 0729d42f053..6240fd4eec4 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,7 +21,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 799e10bc15c..8edf98e569e 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -9,6 +9,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -86,26 +87,29 @@ describe("WebLoginComponentService", () => { }); describe("getOrgPoliciesFromOrgInvite", () => { + const mockEmail = "test@example.com"; + const orgInvite: OrganizationInvite = { + organizationId: "org-id", + token: "token", + email: mockEmail, + organizationUserId: "org-user-id", + initOrganization: false, + orgSsoIdentifier: "sso-id", + orgUserHasExistingUser: false, + organizationName: "org-name", + }; + it("returns undefined if organization invite is null", async () => { organizationInviteService.getOrganizationInvite.mockResolvedValue(null); - const result = await service.getOrgPoliciesFromOrgInvite(); + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(result).toBeUndefined(); }); it("logs an error if getPoliciesByToken throws an error", async () => { const error = new Error("Test error"); - organizationInviteService.getOrganizationInvite.mockResolvedValue({ - organizationId: "org-id", - token: "token", - email: "email", - organizationUserId: "org-user-id", - initOrganization: false, - orgSsoIdentifier: "sso-id", - orgUserHasExistingUser: false, - organizationName: "org-name", - }); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockRejectedValue(error); - await service.getOrgPoliciesFromOrgInvite(); + await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(logService.error).toHaveBeenCalledWith(error); }); @@ -120,16 +124,7 @@ describe("WebLoginComponentService", () => { const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled; - organizationInviteService.getOrganizationInvite.mockResolvedValue({ - organizationId: "org-id", - token: "token", - email: "email", - organizationUserId: "org-user-id", - initOrganization: false, - orgSsoIdentifier: "sso-id", - orgUserHasExistingUser: false, - organizationName: "org-name", - }); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockResolvedValue(policies); internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([ @@ -141,7 +136,7 @@ describe("WebLoginComponentService", () => { masterPasswordPolicyOptions, ); - const result = await service.getOrgPoliciesFromOrgInvite(); + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(result).toEqual({ policies: policies, @@ -151,5 +146,40 @@ describe("WebLoginComponentService", () => { }); }, ); + + describe("given the orgInvite email does not match the provided email", () => { + const mockMismatchedEmail = "mismatched@example.com"; + it("should clear the login redirect URL and organization invite", async () => { + // Arrange + organizationInviteService.getOrganizationInvite.mockResolvedValue({ + ...orgInvite, + email: mockMismatchedEmail, + }); + + // Act + await service.getOrgPoliciesFromOrgInvite(mockEmail); + + // Assert + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1); + }); + + it("should log an error and return undefined", async () => { + // Arrange + organizationInviteService.getOrganizationInvite.mockResolvedValue({ + ...orgInvite, + email: mockMismatchedEmail, + }); + + // Act + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); + + // Assert + expect(logService.error).toHaveBeenCalledWith( + `WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${mockMismatchedEmail}, Received: ${mockEmail}`, + ); + expect(result).toBeUndefined(); + }); + }); }); }); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 4ee84ecfde2..5bea0908b0a 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -66,10 +66,27 @@ export class WebLoginComponentService return; } - async getOrgPoliciesFromOrgInvite(): Promise { + async getOrgPoliciesFromOrgInvite(email: string): Promise { const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite != null) { + /** + * Check if the email on the org invite matches the email submitted in the login form. This is + * important because say userA at "userA@mail.com" clicks an emailed org invite link, but then + * on the login page form they change the email to "userB@mail.com". We don't want to apply the org + * invite in state to userB. Therefore we clear the login redirect url as well as the org invite, + * allowing userB to login as normal. + */ + if (orgInvite.email !== email.toLowerCase()) { + await this.routerService.getAndClearLoginRedirectUrl(); + await this.organizationInviteService.clearOrganizationInvitation(); + + this.logService.error( + `WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${orgInvite.email}, Received: ${email}`, + ); + return undefined; + } + let policies: Policy[]; try { diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index b341fc4f8e4..f0ecca1686d 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; 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"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -60,6 +63,11 @@ describe("EmergencyViewDialogComponent", () => { { provide: AccountService, useValue: accountService }, { provide: TaskService, useValue: mock() }, { provide: LogService, useValue: mock() }, + { + provide: EnvironmentService, + useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, + }, + { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, ], }) .overrideComponent(EmergencyViewDialogComponent, { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index a222b668043..965a9d5c99d 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -84,7 +84,7 @@ import { IpcService } from "@bitwarden/common/platform/ipc"; import { UnsupportedWebPushConnectionService, WebPushConnectionService, -} from "@bitwarden/common/platform/notifications/internal"; +} from "@bitwarden/common/platform/server-notifications/internal"; import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index ecf10bfa723..f75d1268053 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -13,7 +13,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { IpcService } from "@bitwarden/common/platform/ipc"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; diff --git a/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts b/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts index 44866285251..c0bc9ed3e77 100644 --- a/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts +++ b/apps/web/src/app/platform/notifications/permissions-webpush-connection.service.ts @@ -5,7 +5,7 @@ import { SupportStatus } from "@bitwarden/common/platform/misc/support-status"; import { WebPushConnector, WorkerWebPushConnectionService, -} from "@bitwarden/common/platform/notifications/internal"; +} from "@bitwarden/common/platform/server-notifications/internal"; import { UserId } from "@bitwarden/common/types/guid"; export class PermissionsWebPushConnectionService extends WorkerWebPushConnectionService { diff --git a/apps/web/src/app/platform/web-environment.service.ts b/apps/web/src/app/platform/web-environment.service.ts index 1df842d6b31..4c4681ff715 100644 --- a/apps/web/src/app/platform/web-environment.service.ts +++ b/apps/web/src/app/platform/web-environment.service.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Router } from "@angular/router"; -import { firstValueFrom, ReplaySubject } from "rxjs"; +import { firstValueFrom, Observable, ReplaySubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { @@ -16,6 +16,7 @@ import { SelfHostedEnvironment, } from "@bitwarden/common/platform/services/default-environment.service"; import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/user-core"; export type WebRegionConfig = RegionConfig & { key: Region | string; // strings are used for custom environments @@ -27,6 +28,8 @@ export type WebRegionConfig = RegionConfig & { * Web specific environment service. Ensures that the urls are set from the window location. */ export class WebEnvironmentService extends DefaultEnvironmentService { + private _environmentSubject: ReplaySubject; + constructor( private win: Window, stateProvider: StateProvider, @@ -60,7 +63,9 @@ export class WebEnvironmentService extends DefaultEnvironmentService { // Override the environment observable with a replay subject const subject = new ReplaySubject(1); subject.next(environment); + this._environmentSubject = subject; this.environment$ = subject.asObservable(); + this.globalEnvironment$ = subject.asObservable(); } // Web setting env means navigating to a new location @@ -100,6 +105,12 @@ export class WebEnvironmentService extends DefaultEnvironmentService { // This return shouldn't matter as we are about to leave the current window return chosenRegionConfig.urls; } + + getEnvironment$(userId: UserId): Observable { + // Web does not support account switching, and even if it did, you'd be required to be the environment of where the application + // is running. + return this._environmentSubject.asObservable(); + } } export class WebCloudEnvironment extends CloudEnvironment { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 658f829128d..ca8b05c2701 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -565,7 +565,7 @@ export class VaultComponent implements OnInit, OnDestr this.refreshing = false; // Explicitly mark for check to ensure the view is updated - // Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS notifications) + // Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS server notifications) this.changeDetectorRef.markForCheck(); }, ); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 587dcd84e0c..4eaf141abc2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11002,5 +11002,11 @@ }, "providersubCanceledmessage": { "message" : "To resubscribe, contact Bitwarden Customer Support." + }, + "showMore": { + "message": "Show more" + }, + "showLess": { + "message": "Show less" } -} \ No newline at end of file +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 06bfce205e6..54902778646 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -204,22 +204,20 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { ServerNotificationsService } from "@bitwarden/common/platform/notifications"; -// eslint-disable-next-line no-restricted-imports -- Needed for service creation -import { - DefaultServerNotificationsService, - UnsupportedServerNotificationsService, - SignalRConnectionService, - UnsupportedWebPushConnectionService, - WebPushConnectionService, - WebPushNotificationsApiService, -} from "@bitwarden/common/platform/notifications/internal"; -import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service"; -import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/notifications/unsupported-system-notifications.service"; import { DefaultTaskSchedulerService, TaskSchedulerService, } from "@bitwarden/common/platform/scheduling"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; +// eslint-disable-next-line no-restricted-imports -- Needed for service creation +import { + DefaultServerNotificationsService, + NoopServerNotificationsService, + SignalRConnectionService, + UnsupportedWebPushConnectionService, + WebPushConnectionService, + WebPushNotificationsApiService, +} from "@bitwarden/common/platform/server-notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -256,6 +254,8 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state/state- import { SyncService } from "@bitwarden/common/platform/sync"; // eslint-disable-next-line no-restricted-imports -- Needed for DI import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { DefaultThemeStateService, ThemeStateService, @@ -978,7 +978,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: ServerNotificationsService, useClass: devFlagEnabled("noopNotifications") - ? UnsupportedServerNotificationsService + ? NoopServerNotificationsService : DefaultServerNotificationsService, deps: [ LogService, diff --git a/libs/angular/src/vault/components/icon.component.html b/libs/angular/src/vault/components/icon.component.html index 2dae3b26cc5..0f14de64e21 100644 --- a/libs/angular/src/vault/components/icon.component.html +++ b/libs/angular/src/vault/components/icon.component.html @@ -1,19 +1,44 @@ - + +
+ @for (item of showItems(); track item.id; let last = $last) { + - + @if (isOrgIcon(item)) { + + } @else { + + } - - - -
  • - - -
  • - +
    + } + @if (allItems().length === 0) { + + + + + } + @if (hasSmallScreen() && allItems().length > 2 && cipher().collectionIds.length > 1) { + + + } +
    +
    diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts index f093cd020b5..ead2979fac7 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts @@ -1,11 +1,17 @@ +import { ComponentRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -14,6 +20,7 @@ import { ItemDetailsV2Component } from "./item-details-v2.component"; describe("ItemDetailsV2Component", () => { let component: ItemDetailsV2Component; let fixture: ComponentFixture; + let componentRef: ComponentRef; const cipher = { id: "cipher1", @@ -46,37 +53,46 @@ describe("ItemDetailsV2Component", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ItemDetailsV2Component], - providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: PlatformUtilsService, useValue: { getClientType: () => ClientType.Web } }, + { + provide: EnvironmentService, + useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, + }, + { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ItemDetailsV2Component); component = fixture.componentInstance; - component.cipher = cipher; - component.organization = organization; - component.collections = [collection, collection2]; - component.folder = folder; + componentRef = fixture.componentRef; + componentRef.setInput("cipher", cipher); + componentRef.setInput("organization", organization); + componentRef.setInput("collections", [collection, collection2]); + componentRef.setInput("folder", folder); + jest.spyOn(component, "hasSmallScreen").mockReturnValue(false); // Mocking small screen check fixture.detectChanges(); }); it("displays all available fields", () => { const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]')); - const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); - const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li')); - const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]')); + const itemDetailsList = fixture.debugElement.queryAll( + By.css('[data-testid="item-details-list"]'), + ); - expect(itemName.nativeElement.value).toBe(cipher.name); - expect(owner.nativeElement.textContent.trim()).toBe(organization.name); - expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([ - collection.name, - collection2.name, - ]); - expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name); + expect(itemName.nativeElement.textContent.trim()).toEqual(cipher.name); + expect(itemDetailsList.length).toBe(4); // Organization, Collection, Collection2, Folder + expect(itemDetailsList[0].nativeElement.textContent.trim()).toContain(organization.name); + expect(itemDetailsList[1].nativeElement.textContent.trim()).toContain(collection.name); + expect(itemDetailsList[2].nativeElement.textContent.trim()).toContain(collection2.name); + expect(itemDetailsList[3].nativeElement.textContent.trim()).toContain(folder.name); }); it("does not render owner when `hideOwner` is true", () => { - component.hideOwner = true; + componentRef.setInput("hideOwner", true); fixture.detectChanges(); const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 8f0fedbe599..6ccd0b7ee61 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -1,19 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; - +import { Component, computed, input, signal } from "@angular/core"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +import { toSignal } from "@angular/core/rxjs-interop"; +import { fromEvent, map, startWith } from "rxjs"; + // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { + ButtonLinkDirective, CardComponent, FormFieldModule, - SectionHeaderComponent, TypographyModule, } from "@bitwarden/components"; @@ -26,20 +29,96 @@ import { OrgIconDirective } from "../../components/org-icon.directive"; CommonModule, JslibModule, CardComponent, - SectionHeaderComponent, TypographyModule, OrgIconDirective, FormFieldModule, + ButtonLinkDirective, ], }) export class ItemDetailsV2Component { - @Input() cipher: CipherView; - @Input() organization?: Organization; - @Input() collections?: CollectionView[]; - @Input() folder?: FolderView; - @Input() hideOwner?: boolean = false; + hideOwner = input(false); + cipher = input.required(); + organization = input(); + folder = input(); + collections = input(); + showAllDetails = signal(false); - get showOwnership() { - return this.cipher.organizationId && this.organization && !this.hideOwner; + showOwnership = computed(() => { + return this.cipher().organizationId && this.organization() && !this.hideOwner(); + }); + + hasSmallScreen = toSignal( + fromEvent(window, "resize").pipe( + map(() => window.innerWidth), + startWith(window.innerWidth), + map((width) => width < 681), + ), + ); + + // Array to hold all details of item. Organization, Collections, and Folder + allItems = computed(() => { + let items: any[] = []; + if (this.showOwnership() && this.organization()) { + items.push(this.organization()); + } + if (this.cipher().collectionIds?.length > 0 && this.collections()) { + items = [...items, ...this.collections()]; + } + if (this.cipher().folderId && this.folder()) { + items.push(this.folder()); + } + return items; + }); + + showItems = computed(() => { + if ( + this.hasSmallScreen() && + this.allItems().length > 2 && + !this.showAllDetails() && + this.cipher().collectionIds?.length > 1 + ) { + return this.allItems().slice(0, 2); + } else { + return this.allItems(); + } + }); + + constructor(private i18nService: I18nService) {} + + toggleShowMore() { + this.showAllDetails.update((value) => !value); + } + + getAriaLabel(item: Organization | CollectionView | FolderView): string { + if (item instanceof Organization) { + return this.i18nService.t("owner") + item.name; + } else if (item instanceof CollectionView) { + return this.i18nService.t("collection") + item.name; + } else if (item instanceof FolderView) { + return this.i18nService.t("folder") + item.name; + } + return ""; + } + + getIconClass(item: Organization | CollectionView | FolderView): string { + if (item instanceof CollectionView) { + return "bwi-collection-shared"; + } else if (item instanceof FolderView) { + return "bwi-folder"; + } + return ""; + } + + getItemTitle(item: Organization | CollectionView | FolderView): string { + if (item instanceof CollectionView) { + return this.i18nService.t("collection"); + } else if (item instanceof FolderView) { + return this.i18nService.t("folder"); + } + return ""; + } + + isOrgIcon(item: Organization | CollectionView | FolderView): boolean { + return item instanceof Organization; } } diff --git a/package-lock.json b/package-lock.json index 3bf9db97ddb..715ccd8bff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,7 @@ "@typescript-eslint/rule-tester": "8.31.0", "@typescript-eslint/utils": "8.31.0", "@webcomponents/custom-elements": "1.6.0", - "@yao-pkg/pkg": "5.16.1", + "@yao-pkg/pkg": "6.5.1", "angular-eslint": "19.6.0", "autoprefixer": "10.4.21", "axe-playwright": "2.1.0", @@ -192,7 +192,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.7.0" + "version": "2025.7.1" }, "apps/cli": { "name": "@bitwarden/cli", @@ -229,10 +229,6 @@ }, "bin": { "bw": "build/bw.js" - }, - "engines": { - "node": "~20", - "npm": "~10" } }, "apps/cli/node_modules/define-lazy-prop": { @@ -14132,34 +14128,39 @@ "license": "Apache-2.0" }, "node_modules/@yao-pkg/pkg": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.16.1.tgz", - "integrity": "sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.5.1.tgz", + "integrity": "sha512-z6XlySYfnqfm1AfVlBN8A3yeAQniIwL7TKQfDCGsswYSVYLt2snbRefQYsfQQ3pw5lVXrZdLqgTjzaqID9IkWA==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.23.0", "@babel/parser": "^7.23.0", "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.16", + "@yao-pkg/pkg-fetch": "3.5.23", "into-stream": "^6.0.0", "minimist": "^1.2.6", "multistream": "^4.1.0", "picocolors": "^1.1.0", "picomatch": "^4.0.2", "prebuild-install": "^7.1.1", - "resolve": "^1.22.0", + "resolve": "^1.22.10", "stream-meter": "^1.0.4", - "tinyglobby": "^0.2.9" + "tar": "^7.4.3", + "tinyglobby": "^0.2.11", + "unzipper": "^0.12.3" }, "bin": { "pkg": "lib-es5/bin.js" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.16", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.16.tgz", - "integrity": "sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==", + "version": "3.5.23", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.23.tgz", + "integrity": "sha512-rn45sqVQSkcJNSBdTnYze3n+kyub4CN8aiWYlPgA9yp9FZeEF+BlpL68kSIm3HaVuANniF+7RBMH5DkC4zlHZA==", "dev": true, "license": "MIT", "dependencies": { @@ -14261,6 +14262,73 @@ "node": ">=10" } }, + "node_modules/@yao-pkg/pkg/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -15769,6 +15837,13 @@ "node": ">= 6" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -18881,6 +18956,16 @@ "dev": true, "license": "MIT" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -37611,6 +37696,20 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.9.1" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", diff --git a/package.json b/package.json index 6f79985c9df..0d8ba9989b4 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@typescript-eslint/rule-tester": "8.31.0", "@typescript-eslint/utils": "8.31.0", "@webcomponents/custom-elements": "1.6.0", - "@yao-pkg/pkg": "5.16.1", + "@yao-pkg/pkg": "6.5.1", "angular-eslint": "19.6.0", "autoprefixer": "10.4.21", "axe-playwright": "2.1.0",