diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 6845e5f3829..b264514e736 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -79,7 +79,6 @@ matchPackageNames: [ "@emotion/css", "@webcomponents/custom-elements", - "bitwarden-russh", "concurrently", "cross-env", "del", @@ -562,5 +561,6 @@ "node-ipc", "@bitwarden/sdk-internal", "@bitwarden/commercial-sdk-internal", + "bitwarden-russh", ], } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5026f5e2799..972fd60cc2e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3094,29 +3094,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e0cbcdc9f96..eb6d26357eb 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1110,7 +1110,6 @@ export default class MainBackground { this.logService, this.platformUtilsService, this.configService, - this.sdkService, ), ); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 1f1d4d25b40..7fb466449f2 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -69,7 +69,7 @@ import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component"; import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component"; -import { firefoxPopoutGuard } from "../tools/popup/guards/firefox-popout.guard"; +import { filePickerPopoutGuard } from "../tools/popup/guards/file-picker-popout.guard"; import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component"; import { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.component"; import { SendV2Component } from "../tools/popup/send-v2/send-v2.component"; @@ -248,7 +248,7 @@ const routes: Routes = [ { path: "attachments", component: AttachmentsV2Component, - canActivate: [authGuard], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 4 } satisfies RouteDataProperties, }, { @@ -266,7 +266,7 @@ const routes: Routes = [ { path: "import", component: ImportBrowserV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { @@ -350,13 +350,13 @@ const routes: Routes = [ { path: "add-send", component: SendAddEditV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "edit-send", component: SendAddEditV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d178cee2fc3..4ed79dd144d 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -33,7 +33,6 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../platform/popup/layout/popup-page.component"; import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component"; -import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; @@ -67,7 +66,6 @@ import "../platform/popup/locales"; ScrollingModule, ServicesModule, DialogModule, - FilePopoutCalloutComponent, AvatarModule, AccountComponent, ButtonModule, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a8bfb23d83f..a3caaa95fa2 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -230,7 +230,6 @@ import { isNotificationsSupported, } from "../../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; -import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { BrowserAutofillNudgeService } from "../../vault/popup/services/browser-autofill-nudge.service"; import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service"; import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -502,13 +501,6 @@ const safeProviders: SafeProvider[] = [ }, deps: [PlatformUtilsService], }), - safeProvider({ - provide: FilePopoutUtilsService, - useFactory: (platformUtilsService: PlatformUtilsService) => { - return new FilePopoutUtilsService(platformUtilsService); - }, - deps: [PlatformUtilsService], - }), safeProvider({ provide: DerivedStateProvider, useClass: InlineDerivedStateProvider, diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.html b/apps/browser/src/tools/popup/components/file-popout-callout.component.html deleted file mode 100644 index 0df77dc4367..00000000000 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.html +++ /dev/null @@ -1,11 +0,0 @@ - -
{{ "sendLinuxChromiumFileWarning" | i18n }}
-
{{ "sendFirefoxFileWarning" | i18n }}
-
{{ "sendSafariFileWarning" | i18n }}
-
diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts deleted file mode 100644 index 33044b79351..00000000000 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CalloutModule } from "@bitwarden/components"; - -import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../services/file-popout-utils.service"; - -// 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: "tools-file-popout-callout", - templateUrl: "file-popout-callout.component.html", - imports: [CommonModule, JslibModule, CalloutModule], -}) -export class FilePopoutCalloutComponent implements OnInit { - protected showFilePopoutMessage: boolean = false; - protected showFirefoxFileWarning: boolean = false; - protected showSafariFileWarning: boolean = false; - protected showChromiumFileWarning: boolean = false; - - constructor(private filePopoutUtilsService: FilePopoutUtilsService) {} - - ngOnInit() { - this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window); - this.showFirefoxFileWarning = this.filePopoutUtilsService.showFirefoxFileWarning(window); - this.showSafariFileWarning = this.filePopoutUtilsService.showSafariFileWarning(window); - this.showChromiumFileWarning = this.filePopoutUtilsService.showChromiumFileWarning(window); - } - - popOutWindow() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserPopupUtils.openCurrentPagePopout(window); - } -} diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts new file mode 100644 index 00000000000..2f100ab67f2 --- /dev/null +++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts @@ -0,0 +1,834 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; + +import { DeviceType } from "@bitwarden/common/enums"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; +import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; + +import { filePickerPopoutGuard } from "./file-picker-popout.guard"; + +describe("filePickerPopoutGuard", () => { + let getDeviceSpy: jest.SpyInstance; + let inPopoutSpy: jest.SpyInstance; + let inSidebarSpy: jest.SpyInstance; + let openPopoutSpy: jest.SpyInstance; + let closePopupSpy: jest.SpyInstance; + + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState: RouterStateSnapshot = { + url: "/add-send?type=1", + } as RouterStateSnapshot; + + beforeEach(() => { + getDeviceSpy = jest.spyOn(BrowserPlatformUtilsService, "getDevice"); + inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout"); + inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar"); + openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Firefox browser", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout and block navigation when not in popout or sidebar", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(inPopoutSpy).toHaveBeenCalledWith(window); + expect(inSidebarSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should allow navigation when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when already in sidebar", async () => { + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Safari browser", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.SafariExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout and block navigation when not in popout", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(inPopoutSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should allow navigation when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should not allow sidebar bypass (Safari doesn't support sidebar)", async () => { + inSidebarSpy.mockReturnValue(true); + inPopoutSpy.mockReturnValue(false); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + // Safari requires popout, sidebar is not sufficient + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + }); + + describe("Chromium browsers on Linux", () => { + beforeEach(() => { + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + appVersion: "5.0 (X11; Linux x86_64)", + }, + configurable: true, + writable: true, + }); + }); + + it.each([ + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + { deviceType: DeviceType.OperaExtension, name: "Opera" }, + { deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" }, + ])( + "should open popout and block navigation for $name on Linux when not in popout or sidebar", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + + it("should allow navigation when in popout", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when in sidebar", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Chromium browsers on Mac", () => { + beforeEach(() => { + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + appVersion: "5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + configurable: true, + writable: true, + }); + }); + + it.each([ + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + { deviceType: DeviceType.OperaExtension, name: "Opera" }, + { deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" }, + ])( + "should open popout and block navigation for $name on Mac when not in popout or sidebar", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + + it("should allow navigation when in popout", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when in sidebar", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Chromium browsers on Windows", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + appVersion: "5.0 (Windows NT 10.0; Win64; x64)", + }, + configurable: true, + writable: true, + }); + }); + + it("should allow navigation without popout on Windows", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("File picker routes", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it.each([ + { route: "/import" }, + { route: "/add-send" }, + { route: "/edit-send" }, + { route: "/attachments" }, + ])("should open popout for $route route", async ({ route }) => { + const importState: RouterStateSnapshot = { + url: route, + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, importState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#" + route); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + }); + + describe("Url handling", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should preserve query parameters in the popout url", async () => { + const stateWithQuery: RouterStateSnapshot = { + url: "/import?foo=bar&baz=qux", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?foo=bar&baz=qux"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + + it("should handle urls without query parameters", async () => { + const stateWithoutQuery: RouterStateSnapshot = { + url: "/simple-path", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/simple-path"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + + it("should not add autoClosePopout parameter to the url", async () => { + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(openPopoutSpy).not.toHaveBeenCalledWith(expect.stringContaining("autoClosePopout")); + }); + }); + + describe("Send type differentiation", () => { + describe("Text Sends (type=0)", () => { + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox" }, + { deviceType: DeviceType.SafariExtension, name: "Safari" }, + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + ])( + "should allow navigation without popout for new text Sends on $name", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const textSendState: RouterStateSnapshot = { + url: "/add-send?type=0&isNew=true", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, textSendState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }, + ); + + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox" }, + { deviceType: DeviceType.SafariExtension, name: "Safari" }, + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + ])("should allow navigation for editing text Sends on $name", async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const editTextSendState: RouterStateSnapshot = { + url: "/edit-send?sendId=abc123&type=0", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, editTextSendState), + ); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("File Sends (type=1)", () => { + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: true, + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + ])( + "should require popout for a new file Send on $name $os", + async ({ deviceType, userAgent, expectPopout }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const fileSendState: RouterStateSnapshot = { + url: "/add-send?type=1&isNew=true", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, fileSendState)); + + if (expectPopout === false) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/add-send?type=1&isNew=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: true, + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + ])( + "should require popout for editing a file Send on $name $os", + async ({ deviceType, userAgent, expectPopout }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const editFileSendState: RouterStateSnapshot = { + url: "/edit-send?sendId=abc123&type=1", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, editFileSendState), + ); + + if (expectPopout === false) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/edit-send?sendId=abc123&type=1", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + }); + + describe("Send routes without type parameter", () => { + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + ])( + "should default to requiring popout on $name $os", + async ({ deviceType, userAgent, os }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const noTypeState: RouterStateSnapshot = { + url: "/add-send", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, noTypeState)); + + // Windows Chrome/Edge don't need popout + const isChromiumOnWindows = + (deviceType === DeviceType.ChromeExtension || + deviceType === DeviceType.EdgeExtension) && + os === "Windows"; + + if (isChromiumOnWindows) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + ])( + "should default to requiring popout when type is invalid on $name $os", + async ({ deviceType, userAgent, os }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const invalidTypeState: RouterStateSnapshot = { + url: "/add-send?type=invalid", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, invalidTypeState), + ); + + // Windows Chrome/Edge don't need popout + const isChromiumOnWindows = + (deviceType === DeviceType.ChromeExtension || + deviceType === DeviceType.EdgeExtension) && + os === "Windows"; + + if (isChromiumOnWindows) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=invalid"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + }); + + describe("non-Send routes", () => { + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/import" }, + { deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/attachments" }, + { deviceType: DeviceType.SafariExtension, name: "Safari", route: "/import" }, + { deviceType: DeviceType.SafariExtension, name: "Safari", route: "/attachments" }, + ])( + "should always require popout for $route on $name regardless of query params", + async ({ deviceType, route }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const routeState: RouterStateSnapshot = { + url: `${route}?type=0`, + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, routeState)); + + expect(openPopoutSpy).toHaveBeenCalledWith(`popup/index.html#${route}?type=0`); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + }); + }); +}); diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts new file mode 100644 index 00000000000..900ff328ac8 --- /dev/null +++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts @@ -0,0 +1,109 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router"; + +import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; +import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils"; +import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + +/** + * Composite guard that handles file picker popout requirements for all browsers. + * Forces a popout window when file pickers could be exposed on browsers that require it. + * + * Browser-specific requirements: + * - Firefox: Requires sidebar OR popout (crashes with file picker in popup: https://bugzilla.mozilla.org/show_bug.cgi?id=1292701) + * - Safari: Requires popout only + * - All Chromium browsers (Chrome, Edge, Opera, Vivaldi) on Linux/Mac: Requires sidebar OR popout + * - Chromium on Windows: No special requirement + * + * Send-specific behavior: + * - Text Sends: No popout required (no file picker needed) + * - File Sends: Popout required on affected browsers + * + * @returns CanActivateFn that opens popout and blocks navigation when file picker access is needed + */ +export function filePickerPopoutGuard(): CanActivateFn { + return async (_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + // Check if this is a text Send route (no file picker needed) + if (isTextOnlySendRoute(state.url)) { + return true; // Allow navigation without popout + } + + // Check if browser is one that needs popout for file pickers + const deviceType = BrowserPlatformUtilsService.getDevice(window); + + // Check current context + const inPopout = BrowserPopupUtils.inPopout(window); + const inSidebar = BrowserPopupUtils.inSidebar(window); + + let needsPopout = false; + + // Firefox: needs sidebar OR popout to avoid crash with file picker + if (deviceType === DeviceType.FirefoxExtension && !inPopout && !inSidebar) { + needsPopout = true; + } + + // Safari: needs popout only (sidebar not available) + if (deviceType === DeviceType.SafariExtension && !inPopout) { + needsPopout = true; + } + + // Chromium on Linux/Mac: needs sidebar OR popout for file picker access + // All Chromium-based browsers (Chrome, Edge, Opera, Vivaldi) + // Brave intentionally reports itself as Chrome for compatibility + const isChromiumBased = [ + DeviceType.ChromeExtension, + DeviceType.EdgeExtension, + DeviceType.OperaExtension, + DeviceType.VivaldiExtension, + ].includes(deviceType); + + const isLinux = window?.navigator?.userAgent?.includes("Linux"); + const isMac = window?.navigator?.userAgent?.includes("Mac OS X"); + + if (isChromiumBased && (isLinux || isMac) && !inPopout && !inSidebar) { + needsPopout = true; + } + + // Open popout if needed + if (needsPopout) { + // Don't add autoClosePopout for file picker scenarios - user should manually close + await BrowserPopupUtils.openPopout(`popup/index.html#${state.url}`); + + // Close the original popup window + BrowserApi.closePopup(window); + + return false; // Block navigation - popout will reload + } + + return true; // Allow navigation + }; +} + +/** + * Determines if the route is for a text Send that doesn't require file picker display. + * + * @param url The route URL with query parameters + * @returns true if this is a Send route with explicitly text type (SendType.Text = 0) + */ +function isTextOnlySendRoute(url: string): boolean { + // Only apply to Send routes + if (!url.includes("/add-send") && !url.includes("/edit-send")) { + return false; + } + + // Parse query parameters to check Send type + const queryStartIndex = url.indexOf("?"); + if (queryStartIndex === -1) { + // No query params - default to requiring popout for safety + return false; + } + + const queryString = url.substring(queryStartIndex + 1); + const params = new URLSearchParams(queryString); + const typeParam = params.get("type"); + + // Only skip popout for explicitly text-based Sends (SendType.Text = 0) + // If type is missing, null, or not text, default to requiring popout + return typeParam === String(SendType.Text); +} diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index a72847a5bf2..2d588a9ee78 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -10,8 +10,6 @@ > - - - - - diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts deleted file mode 100644 index 23fa744995a..00000000000 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components"; - -import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils"; - -// 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: "send-file-popout-dialog", - templateUrl: "./send-file-popout-dialog.component.html", - imports: [JslibModule, CommonModule, DialogModule, ButtonModule, TypographyModule], -}) -export class SendFilePopoutDialogComponent { - constructor(private dialogService: DialogService) {} - - async popOutWindow() { - await BrowserPopupUtils.openCurrentPagePopout(window); - } - - close() { - this.dialogService.closeAll(); - } -} diff --git a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts b/apps/browser/src/tools/popup/services/file-popout-utils.service.ts deleted file mode 100644 index 9a04d4b8f23..00000000000 --- a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; - -/** - * Service for determining whether to display file popout callout messages. - */ -@Injectable() -export class FilePopoutUtilsService { - /** - * Creates an instance of FilePopoutUtilsService. - */ - constructor(private platformUtilsService: PlatformUtilsService) {} - - /** - * Determines whether to show any file popout callout message in the current browser. - * @param win - The window context in which the check should be performed. - * @returns True if a file popout callout message should be displayed; otherwise, false. - */ - showFilePopoutMessage(win: Window): boolean { - return ( - this.showFirefoxFileWarning(win) || - this.showSafariFileWarning(win) || - this.showChromiumFileWarning(win) - ); - } - - /** - * Determines whether to show a file popout callout message for the Firefox browser - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a sidebar or popout; otherwise, false. - */ - showFirefoxFileWarning(win: Window): boolean { - return ( - this.platformUtilsService.isFirefox() && - !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) - ); - } - - /** - * Determines whether to show a file popout message for the Safari browser - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a popout; otherwise, false. - */ - showSafariFileWarning(win: Window): boolean { - return this.platformUtilsService.isSafari() && !BrowserPopupUtils.inPopout(win); - } - - /** - * Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a sidebar or popout; otherwise, false. - */ - showChromiumFileWarning(win: Window): boolean { - return ( - (this.isLinux(win) || this.isUnsupportedMac(win)) && - !this.platformUtilsService.isFirefox() && - !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) - ); - } - - private isLinux(win: Window): boolean { - return win?.navigator?.userAgent.indexOf("Linux") !== -1; - } - - private isUnsupportedMac(win: Window): boolean { - return this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X"); - } -} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html index 0fbe1c55b0a..1e9d63b709b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html @@ -4,14 +4,15 @@ type="button" (click)="openAttachments()" [disabled]="parentFormDisabled" + [title]="'popOutNewWindow' | i18n" >
{{ "attachments" | i18n }}
- - + {{ "popOutNewWindow" | i18n }} + diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index e9636e09873..b88b435c702 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -20,9 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ToastService } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; -import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service"; - import { OpenAttachmentsComponent } from "./open-attachments.component"; describe("OpenAttachmentsComponent", () => { @@ -31,9 +28,6 @@ describe("OpenAttachmentsComponent", () => { let router: Router; const showToast = jest.fn(); const hasPremiumFromAnySource$ = new BehaviorSubject(true); - const openCurrentPagePopout = jest - .spyOn(BrowserPopupUtils, "openCurrentPagePopout") - .mockResolvedValue(null); const cipherView = { id: "5555-444-3333", type: CipherType.Login, @@ -55,7 +49,6 @@ describe("OpenAttachmentsComponent", () => { const getCipher = jest.fn().mockResolvedValue(cipherDomain); const organizations$ = jest.fn().mockReturnValue(of([org])); - const showFilePopoutMessage = jest.fn().mockReturnValue(false); const mockUserId = Utils.newGuid() as UserId; const accountService = { @@ -70,11 +63,9 @@ describe("OpenAttachmentsComponent", () => { const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled"); beforeEach(async () => { - openCurrentPagePopout.mockClear(); getCipher.mockClear(); showToast.mockClear(); organizations$.mockClear(); - showFilePopoutMessage.mockClear(); hasPremiumFromAnySource$.next(true); formStatusChange$.next("enabled"); @@ -103,10 +94,6 @@ describe("OpenAttachmentsComponent", () => { provide: OrganizationService, useValue: { organizations$ }, }, - { - provide: FilePopoutUtilsService, - useValue: { showFilePopoutMessage }, - }, { provide: AccountService, useValue: accountService, @@ -130,8 +117,7 @@ describe("OpenAttachmentsComponent", () => { fixture.detectChanges(); }); - it("opens attachments in new popout", async () => { - showFilePopoutMessage.mockReturnValue(true); + it("navigates to attachments route", async () => { component.canAccessAttachments = true; await component.ngOnInit(); @@ -140,20 +126,6 @@ describe("OpenAttachmentsComponent", () => { expect(router.navigate).toHaveBeenCalledWith(["/attachments"], { queryParams: { cipherId: "5555-444-3333" }, }); - expect(openCurrentPagePopout).toHaveBeenCalledWith(window); - }); - - it("opens attachments in same window", async () => { - showFilePopoutMessage.mockReturnValue(false); - component.canAccessAttachments = true; - await component.ngOnInit(); - - await component.openAttachments(); - - expect(openCurrentPagePopout).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(["/attachments"], { - queryParams: { cipherId: "5555-444-3333" }, - }); }); it("routes the user to the premium page when they cannot access premium features", async () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index a267e7999ab..1a1f767ca8c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -23,9 +23,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; -import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -46,9 +43,6 @@ export class OpenAttachmentsComponent implements OnInit { // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipherId: CipherId; - /** True when the attachments window should be opened in a popout */ - openAttachmentsInPopout: boolean; - /** True when the user has access to premium or h */ canAccessAttachments: boolean; @@ -65,7 +59,6 @@ export class OpenAttachmentsComponent implements OnInit { private organizationService: OrganizationService, private toastService: ToastService, private i18nService: I18nService, - private filePopoutUtilsService: FilePopoutUtilsService, private accountService: AccountService, private cipherFormContainer: CipherFormContainer, private premiumUpgradeService: PremiumUpgradePromptService, @@ -87,8 +80,6 @@ export class OpenAttachmentsComponent implements OnInit { } async ngOnInit(): Promise { - this.openAttachmentsInPopout = this.filePopoutUtilsService.showFilePopoutMessage(window); - if (!this.cipherId) { return; } @@ -131,12 +122,5 @@ export class OpenAttachmentsComponent implements OnInit { } await this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipherId } }); - - // Open the attachments page in a popout - // This is done after the router navigation to ensure that the navigation - // is included in the `PopupRouterCacheService` history - if (this.openAttachmentsInPopout) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index a322fbc53dd..a956b2fe68b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -421,29 +421,13 @@ describe("VaultV2Component", () => { expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1); }); - it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => { - (BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true); - + it("navigateToImport navigates to import route", fakeAsync(async () => { const ngRouter = TestBed.inject(Router); jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any); await component["navigateToImport"](); expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]); - - expect(BrowserPopupUtils.openCurrentPagePopout).toHaveBeenCalled(); - })); - - it("navigateToImport does not popout when popup is not open", fakeAsync(async () => { - (BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(false); - - const ngRouter = TestBed.inject(Router); - jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any); - - await component["navigateToImport"](); - - expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]); - expect(BrowserPopupUtils.openCurrentPagePopout).not.toHaveBeenCalled(); })); it("ngOnInit dismisses intro carousel and opens decryption dialog for non-deleted failures", fakeAsync(() => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index fce084542a9..a5a74eb8ab8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -56,8 +56,6 @@ import { } from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; -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 { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; @@ -370,9 +368,6 @@ export class VaultV2Component implements OnInit, OnDestroy { async navigateToImport() { await this.router.navigate(["/import"]); - if (await BrowserApi.isPopupOpen()) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } async dismissVaultNudgeSpotlight(type: NudgeType) { diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index ad009c7a60b..c84188af863 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -13,7 +13,7 @@ - diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c1d90d678cb..c35345bd8ab 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -15,8 +15,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; -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 { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -90,9 +88,6 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { async import() { await this.router.navigate(["/import"]); - if (await BrowserApi.isPopupOpen()) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } async sync() { diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 105d80b290b..3e78eb36577 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -928,7 +928,6 @@ export class ServiceContainer { this.logService, this.platformUtilsService, this.configService, - this.sdkService, ), ); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7203e28d3ad..cd2147d21e4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2026.1.0", + "version": "2026.2.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 0df71a78412..271418ae5b2 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -1,14 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { - ChangeDetectorRef, - Component, - computed, - effect, - inject, - signal, - viewChild, -} from "@angular/core"; +import { Component, computed, inject, signal, viewChild } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { combineLatest, map, switchMap, lastValueFrom } from "rxjs"; @@ -92,7 +84,6 @@ export class SendV2Component { private dialogService = inject(DialogService); private toastService = inject(ToastService); private logService = inject(LogService); - private cdr = inject(ChangeDetectorRef); protected readonly useDrawerEditMode = toSignal( this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2), @@ -137,17 +128,6 @@ export class SendV2Component { { initialValue: null }, ); - constructor() { - // WORKAROUND: Force change detection when data updates - // This is needed because SendSearchComponent (shared lib) hasn't migrated to OnPush yet - // and doesn't trigger CD properly when search/add operations complete - // TODO: Remove this once SendSearchComponent migrates to OnPush (tracked in CL-764) - effect(() => { - this.filteredSends(); - this.cdr.markForCheck(); - }); - } - protected readonly selectedSendType = computed(() => { const action = this.action(); @@ -171,8 +151,6 @@ export class SendV2Component { } else { this.action.set(Action.Add); this.sendId.set(null); - - this.cdr.detectChanges(); void this.addEditComponent()?.resetAndLoad(); } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 08cbdb913e6..0aa188eba2f 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2026.1.0", + "version": "2026.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2026.1.0", + "version": "2026.2.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 859a18fefd0..0076981ab60 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2026.1.0", + "version": "2026.2.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts index 35f958e495d..8aed38bf082 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts @@ -80,6 +80,7 @@ export class VaultFilterComponent implements OnInit { protected readonly showCollectionsFilter = computed(() => { return ( this.organizations() != null && + this.nonIndividualVaultOrganizations().length > 0 && !this.activeFilter()?.isMyVaultSelected && !this.allOrganizationsDisabled() ); @@ -89,10 +90,14 @@ export class VaultFilterComponent implements OnInit { if (!this.organizations()) { return false; } - const orgs = this.organizations().children.filter((org) => org.node.id !== "MyVault"); + const orgs = this.nonIndividualVaultOrganizations(); return orgs.length > 0 && orgs.every((org) => !org.node.enabled); }); + private nonIndividualVaultOrganizations() { + return this.organizations().children.filter((org) => org.node.id !== "MyVault"); + } + private async setActivePolicies() { this.activeOrganizationDataOwnershipPolicy = await firstValueFrom( this.policyService.policyAppliesToUser$( diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index 5d05e703fd4..17d5fdea064 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -30,7 +30,7 @@ > } -
+
@let hideVaultFilters = hideVaultFilter$ | async; @if (!hideVaultFilters) {
@@ -43,7 +43,9 @@
} -
+
@if (showAddAccessToggle && activeFilter.selectedCollectionNode) { {{ "nonCompliantMembersError" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index 154a683b0e1..3228b9a94ce 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -88,12 +88,9 @@ export class BulkRestoreRevokeComponent { const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage"; response.data.forEach(async (entry) => { - const error = - entry.error !== "" - ? this.i18nService.t("cannotRestoreAccessError") - : this.i18nService.t(bulkMessage); - this.statuses.set(entry.id, error); - if (entry.error !== "") { + const status = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage); + this.statuses.set(entry.id, status); + if (entry.error !== "" && !this.isRevoking) { this.nonCompliantMembers = true; } }); diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html index 921004e315d..65bab31c728 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.html @@ -136,7 +136,7 @@ *ngIf="showBulkReinviteUsers" > - {{ "reinviteSelected" | i18n }} + {{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }} } @if (bulkActions.showBulkConfirmUsers) { 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 e3ed575d81b..36c207219a0 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 @@ -125,6 +125,16 @@ export class vNextMembersComponent { .usersUpdated() .pipe(map(() => showConfirmBanner(this.dataSource()))); + protected selectedInvitedCount$ = this.dataSource() + .usersUpdated() + .pipe( + map( + (members) => members.filter((m) => m.status === OrganizationUserStatusType.Invited).length, + ), + ); + + protected isSingleInvite$ = this.selectedInvitedCount$.pipe(map((count) => count === 1)); + protected isProcessing = this.memberActionsService.isProcessing; protected readonly canUseSecretsManager: Signal = computed( diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index f775ed84ede..bd061bf34d3 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -193,7 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy { formConfig, activeCollectionId, disableForm, - isAdminConsoleAction: true, + isAdminConsoleAction: this.organization != null, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 4b833e771dd..995169e3dc1 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -13,11 +13,11 @@ bitTypography="h1" noMargin class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1" - [title]="title || (routeData.titleId | i18n)" + [title]="title() || (routeData.titleId | i18n)" >
- - {{ title || (routeData.titleId | i18n) }} + + {{ title() || (routeData.titleId | i18n) }}
diff --git a/apps/web/src/app/layouts/header/web-header.component.ts b/apps/web/src/app/layouts/header/web-header.component.ts index 694ee5c4ae9..45ed32e61bb 100644 --- a/apps/web/src/app/layouts/header/web-header.component.ts +++ b/apps/web/src/app/layouts/header/web-header.component.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { Component, input, InputSignal } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, Observable } from "rxjs"; @@ -25,19 +23,15 @@ export class WebHeaderComponent { /** * Custom title that overrides the route data `titleId` */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; + readonly title: InputSignal = input(); /** * Icon to show before the title */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: string; + readonly icon: InputSignal = input(); protected routeData$: Observable<{ titleId: string }>; - protected account$: Observable; + protected account$: Observable<(User & { id: UserId }) | null>; protected canLock$: Observable; protected selfHosted: boolean; protected hostname = location.hostname; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index cb5332d07d8..f54bfb1dda2 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -83,7 +83,7 @@ {{ "loading" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html index e0b29dffeb8..5478601e72c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html @@ -89,7 +89,7 @@ *ngIf="showBulkReinviteUsers" > - {{ "reinviteSelected" | i18n }} + {{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }} } @if (bulkMenuOptions.showBulkConfirmUsers) { 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 a2330be4c6f..3efeee17100 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 @@ -104,6 +104,14 @@ export class vNextMembersComponent { .usersUpdated() .pipe(map(() => showConfirmBanner(this.dataSource()))); + protected selectedInvitedCount$ = this.dataSource() + .usersUpdated() + .pipe( + map((members) => members.filter((m) => m.status === ProviderUserStatusType.Invited).length), + ); + + protected isSingleInvite$ = this.selectedInvitedCount$.pipe(map((count) => count === 1)); + protected isProcessing = this.providerActionsService.isProcessing; constructor() { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 79de741b67a..447481a8bcb 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -123,7 +123,9 @@ const routes: Routes = [ }, { path: "billing", - canActivate: [providerPermissionsGuard()], + canActivate: [ + providerPermissionsGuard((provider: Provider) => provider.isProviderAdmin), + ], children: [ { path: "", 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 2fa9fabf73d..81304855c8c 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 @@ -6,7 +6,7 @@
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html new file mode 100644 index 00000000000..6c04ea87960 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html @@ -0,0 +1,11 @@ +@let integrationsList = integrations(); + +
+

+ {{ "deviceManagement" | i18n }} +

+

{{ "deviceManagementDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts new file mode 100644 index 00000000000..18e6dc7e362 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts @@ -0,0 +1,25 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "device-management", + templateUrl: "device-management.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class DeviceManagementComponent { + integrations = this.state.integrations; + + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html new file mode 100644 index 00000000000..9a767e52c8b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html @@ -0,0 +1,11 @@ +@let integrationsList = integrations(); + +
+

+ {{ "eventManagement" | i18n }} +

+

{{ "eventManagementDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts new file mode 100644 index 00000000000..70b17cabd35 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts @@ -0,0 +1,24 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "event-management", + templateUrl: "event-management.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class EventManagementComponent { + integrations = this.state.integrations; + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index 14f20a0b71c..fbff31f026e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -1,82 +1,18 @@ - +@let org = organization(); -@let organization = organization$ | async; + + @if (org) { + + {{ "singleSignOn" | i18n }} + @if (org.useScim || org.useDirectory) { + {{ "userProvisioning" | i18n }} + } + @if (org.useEvents) { + {{ "eventManagement" | i18n }} + } + {{ "deviceManagement" | i18n }} + + } + -@if (organization) { - - @if (organization?.useSso) { - -
-

{{ "singleSignOn" | i18n }}

-

- {{ "ssoDescStart" | i18n }} - {{ - "singleSignOn" | i18n - }} - {{ "ssoDescEnd" | i18n }} -

- -
-
- } - - @if (organization?.useScim || organization?.useDirectory) { - - @if (organization?.useScim) { -
-

- {{ "scimIntegration" | i18n }} -

-

- {{ "scimIntegrationDescStart" | i18n }} - {{ "scimIntegration" | i18n }} - {{ "scimIntegrationDescEnd" | i18n }} -

- -
- } - @if (organization?.useDirectory) { -
-

- {{ "bwdc" | i18n }} -

-

{{ "bwdcDesc" | i18n }}

- -
- } -
- } - - @if (organization?.useEvents) { - -
-

- {{ "eventManagement" | i18n }} -

-

{{ "eventManagementDesc" | i18n }}

- -
-
- } - - -
-

- {{ "deviceManagement" | i18n }} -

-

{{ "deviceManagementDesc" | i18n }}

- -
-
-
-} + diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 5485410f735..786aa70bfc5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -1,336 +1,22 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs"; +import { Component } from "@angular/core"; -import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; -import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { IntegrationType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { getById } from "@bitwarden/common/platform/misc"; +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { IntegrationGridComponent } from "./integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "./integrations.pipe"; +import { OrganizationIntegrationsState } from "./organization-integrations.state"; -// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved // 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: "ac-integrations", templateUrl: "./integrations.component.html", - imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe], + imports: [SharedModule, HeaderModule], }) -export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { - tabIndex: number = 0; - organization$: Observable = new Observable(); - isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; - isEventManagementForHuntressEnabled: boolean = false; - private destroy$ = new Subject(); +export class AdminConsoleIntegrationsComponent { + organization = this.state.organization; - // initialize the integrations list with default integrations - integrationsList: Integration[] = [ - { - name: "AD FS", - linkURL: "https://bitwarden.com/help/saml-adfs/", - image: "../../../../../../../images/integrations/azure-active-directory.svg", - type: IntegrationType.SSO, - }, - { - name: "Auth0", - linkURL: "https://bitwarden.com/help/saml-auth0/", - image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "AWS", - linkURL: "https://bitwarden.com/help/saml-aws/", - image: "../../../../../../../images/integrations/aws-color.svg", - imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/saml-azure/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Duo", - linkURL: "https://bitwarden.com/help/saml-duo/", - image: "../../../../../../../images/integrations/logo-duo-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Google", - linkURL: "https://bitwarden.com/help/saml-google/", - image: "../../../../../../../images/integrations/logo-google-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "JumpCloud", - linkURL: "https://bitwarden.com/help/saml-jumpcloud/", - image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "KeyCloak", - linkURL: "https://bitwarden.com/help/saml-keycloak/", - image: "../../../../../../../images/integrations/logo-keycloak-icon.svg", - type: IntegrationType.SSO, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/saml-okta/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/saml-onelogin/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "PingFederate", - linkURL: "https://bitwarden.com/help/saml-pingfederate/", - image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.SCIM, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/okta-scim-integration/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/onelogin-scim-integration/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "JumpCloud", - linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/", - image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "Ping Identity", - linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/", - image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", - type: IntegrationType.SCIM, - }, - { - name: "Active Directory", - linkURL: "https://bitwarden.com/help/ldap-directory/", - image: "../../../../../../../images/integrations/azure-active-directory.svg", - type: IntegrationType.BWDC, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/microsoft-entra-id/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.BWDC, - }, - { - name: "Google Workspace", - linkURL: "https://bitwarden.com/help/workspace-directory/", - image: "../../../../../../../images/integrations/logo-google-badge-color.svg", - type: IntegrationType.BWDC, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/okta-directory/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.BWDC, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/onelogin-directory/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.BWDC, - }, - { - name: "Splunk", - linkURL: "https://bitwarden.com/help/splunk-siem/", - image: "../../../../../../../images/integrations/logo-splunk-black.svg", - imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg", - type: IntegrationType.EVENT, - }, - { - name: "Microsoft Sentinel", - linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/", - image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Rapid7", - linkURL: "https://bitwarden.com/help/rapid7-siem/", - image: "../../../../../../../images/integrations/logo-rapid7-black.svg", - imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg", - type: IntegrationType.EVENT, - }, - { - name: "Elastic", - linkURL: "https://bitwarden.com/help/elastic-siem/", - image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Panther", - linkURL: "https://bitwarden.com/help/panther-siem/", - image: "../../../../../../../images/integrations/logo-panther-round-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Sumo Logic", - linkURL: "https://bitwarden.com/help/sumo-logic-siem/", - image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg", - imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg", - type: IntegrationType.EVENT, - newBadgeExpiration: "2025-12-31", - }, - { - name: "Microsoft Intune", - linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/", - image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg", - type: IntegrationType.DEVICE, - }, - ]; - - async ngOnInit() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - if (!userId) { - throw new Error("User ID not found"); - } - - this.organization$ = this.route.params.pipe( - switchMap((params) => - this.organizationService.organizations$(userId).pipe( - getById(params.organizationId), - // Filter out undefined values - takeWhile((org: Organization | undefined) => !!org), - ), - ), - ); - - // Sets the organization ID which also loads the integrations$ - this.organization$ - .pipe( - switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)), - takeUntil(this.destroy$), - ) - .subscribe(); - } - - constructor( - private route: ActivatedRoute, - private organizationService: OrganizationService, - private accountService: AccountService, - private configService: ConfigService, - private organizationIntegrationService: OrganizationIntegrationService, - ) { - this.configService - .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike) - .pipe(takeUntil(this.destroy$)) - .subscribe((isEnabled) => { - this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; - }); - - this.configService - .getFeatureFlag$(FeatureFlag.EventManagementForHuntress) - .pipe(takeUntil(this.destroy$)) - .subscribe((isEnabled) => { - this.isEventManagementForHuntressEnabled = isEnabled; - }); - - // Add the new event based items to the list - if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { - const crowdstrikeIntegration: Integration = { - name: OrganizationIntegrationServiceName.CrowdStrike, - linkURL: "https://bitwarden.com/help/crowdstrike-siem/", - image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", - type: IntegrationType.EVENT, - description: "crowdstrikeEventIntegrationDesc", - canSetupConnection: true, - integrationType: OrganizationIntegrationType.Hec, - }; - - this.integrationsList.push(crowdstrikeIntegration); - - const datadogIntegration: Integration = { - name: OrganizationIntegrationServiceName.Datadog, - linkURL: "https://bitwarden.com/help/datadog-siem/", - image: "../../../../../../../images/integrations/logo-datadog-color.svg", - type: IntegrationType.EVENT, - description: "datadogEventIntegrationDesc", - canSetupConnection: true, - integrationType: OrganizationIntegrationType.Datadog, - }; - - this.integrationsList.push(datadogIntegration); - } - - // Add Huntress SIEM integration (separate feature flag) - if (this.isEventManagementForHuntressEnabled) { - const huntressIntegration: Integration = { - name: OrganizationIntegrationServiceName.Huntress, - linkURL: "https://bitwarden.com/help/huntress-siem/", - image: "../../../../../../../images/integrations/logo-huntress-siem.svg", - type: IntegrationType.EVENT, - description: "huntressEventIntegrationDesc", - canSetupConnection: true, - integrationType: OrganizationIntegrationType.Hec, - }; - - this.integrationsList.push(huntressIntegration); - } - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.organizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all event based integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.forEach((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceName); - if (item) { - item.organizationIntegration = integration; - } - }); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } + constructor(private state: OrganizationIntegrationsState) {} // use in the view get IntegrationType(): typeof IntegrationType { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts index 7a420ade4b5..10ee251a921 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts @@ -7,7 +7,10 @@ import { IntegrationType } from "@bitwarden/common/enums"; name: "filterIntegrations", }) export class FilterIntegrationsPipe implements PipeTransform { - transform(integrations: Integration[], type: IntegrationType): Integration[] { + transform(integrations: Integration[] | null | undefined, type: IntegrationType): Integration[] { + if (!integrations) { + return []; + } return integrations.filter((integration) => integration.type === type); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts index 1667689b186..626fc5dee88 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts @@ -3,16 +3,31 @@ import { RouterModule, Routes } from "@angular/router"; import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; +import { DeviceManagementComponent } from "./device-management/device-management.component"; +import { EventManagementComponent } from "./event-management/event-management.component"; import { AdminConsoleIntegrationsComponent } from "./integrations.component"; +import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; +import { OrganizationIntegrationsState } from "./organization-integrations.state"; +import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; +import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; const routes: Routes = [ { path: "", canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)], - component: AdminConsoleIntegrationsComponent, data: { titleId: "integrations", }, + component: AdminConsoleIntegrationsComponent, + providers: [OrganizationIntegrationsState, OrganizationIntegrationsResolver], + resolve: { integrations: OrganizationIntegrationsResolver }, + children: [ + { path: "", redirectTo: "single-sign-on", pathMatch: "full" }, + { path: "single-sign-on", component: SingleSignOnComponent }, + { path: "user-provisioning", component: UserProvisioningComponent }, + { path: "event-management", component: EventManagementComponent }, + { path: "device-management", component: DeviceManagementComponent }, + ], }, ]; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts index 789ae548521..33f389a92a9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts @@ -1,17 +1,30 @@ import { NgModule } from "@angular/core"; +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; +import { EventManagementComponent } from "./event-management/event-management.component"; import { AdminConsoleIntegrationsComponent } from "./integrations.component"; import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module"; +import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; +import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; +import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; @NgModule({ - imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule], + imports: [ + AdminConsoleIntegrationsComponent, + OrganizationIntegrationsRoutingModule, + SingleSignOnComponent, + UserProvisioningComponent, + DeviceManagementComponent, + EventManagementComponent, + ], providers: [ + OrganizationIntegrationsResolver, safeProvider({ provide: OrganizationIntegrationService, useClass: OrganizationIntegrationService, diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts new file mode 100644 index 00000000000..39bd0cc1dcc --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts @@ -0,0 +1,285 @@ +import { Injectable } from "@angular/core"; +import { ActivatedRouteSnapshot, Resolve } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { take, takeWhile } from "rxjs/operators"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; + +import { OrganizationIntegrationsState } from "./organization-integrations.state"; + +@Injectable() +export class OrganizationIntegrationsResolver implements Resolve { + constructor( + private organizationService: OrganizationService, + private accountService: AccountService, + private configService: ConfigService, + private organizationIntegrationService: OrganizationIntegrationService, + private state: OrganizationIntegrationsState, + ) {} + + async resolve(route: ActivatedRouteSnapshot): Promise { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + if (!userId) { + throw new Error("User ID not found"); + } + + const orgId = route.paramMap.get("organizationId")!; + const org = await firstValueFrom( + this.organizationService.organizations$(userId).pipe(getById(orgId), takeWhile(Boolean)), + ); + + this.state.setOrganization(org); + + await firstValueFrom(this.organizationIntegrationService.setOrganizationId(org.id)); + + const integrations: Integration[] = [ + { + name: "AD FS", + linkURL: "https://bitwarden.com/help/saml-adfs/", + image: "../../../../../../../images/integrations/azure-active-directory.svg", + type: IntegrationType.SSO, + }, + { + name: "Auth0", + linkURL: "https://bitwarden.com/help/saml-auth0/", + image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "AWS", + linkURL: "https://bitwarden.com/help/saml-aws/", + image: "../../../../../../../images/integrations/aws-color.svg", + imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/saml-azure/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Duo", + linkURL: "https://bitwarden.com/help/saml-duo/", + image: "../../../../../../../images/integrations/logo-duo-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Google", + linkURL: "https://bitwarden.com/help/saml-google/", + image: "../../../../../../../images/integrations/logo-google-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "JumpCloud", + linkURL: "https://bitwarden.com/help/saml-jumpcloud/", + image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "KeyCloak", + linkURL: "https://bitwarden.com/help/saml-keycloak/", + image: "../../../../../../../images/integrations/logo-keycloak-icon.svg", + type: IntegrationType.SSO, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/saml-okta/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/saml-onelogin/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "PingFederate", + linkURL: "https://bitwarden.com/help/saml-pingfederate/", + image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.SCIM, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/okta-scim-integration/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/onelogin-scim-integration/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "JumpCloud", + linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/", + image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "Ping Identity", + linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/", + image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", + type: IntegrationType.SCIM, + }, + { + name: "Active Directory", + linkURL: "https://bitwarden.com/help/ldap-directory/", + image: "../../../../../../../images/integrations/azure-active-directory.svg", + type: IntegrationType.BWDC, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/microsoft-entra-id/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.BWDC, + }, + { + name: "Google Workspace", + linkURL: "https://bitwarden.com/help/workspace-directory/", + image: "../../../../../../../images/integrations/logo-google-badge-color.svg", + type: IntegrationType.BWDC, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/okta-directory/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.BWDC, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/onelogin-directory/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.BWDC, + }, + { + name: "Splunk", + linkURL: "https://bitwarden.com/help/splunk-siem/", + image: "../../../../../../../images/integrations/logo-splunk-black.svg", + imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg", + type: IntegrationType.EVENT, + }, + { + name: "Microsoft Sentinel", + linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/", + image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Rapid7", + linkURL: "https://bitwarden.com/help/rapid7-siem/", + image: "../../../../../../../images/integrations/logo-rapid7-black.svg", + imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg", + type: IntegrationType.EVENT, + }, + { + name: "Elastic", + linkURL: "https://bitwarden.com/help/elastic-siem/", + image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Panther", + linkURL: "https://bitwarden.com/help/panther-siem/", + image: "../../../../../../../images/integrations/logo-panther-round-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Sumo Logic", + linkURL: "https://bitwarden.com/help/sumo-logic-siem/", + image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg", + imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg", + type: IntegrationType.EVENT, + newBadgeExpiration: "2025-12-31", + }, + { + name: "Microsoft Intune", + linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/", + image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg", + type: IntegrationType.DEVICE, + }, + ]; + + const featureEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike), + ); + + if (featureEnabled) { + integrations.push( + { + name: OrganizationIntegrationServiceName.CrowdStrike, + linkURL: "https://bitwarden.com/help/crowdstrike-siem/", + image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", + type: IntegrationType.EVENT, + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }, + { + name: OrganizationIntegrationServiceName.Datadog, + linkURL: "https://bitwarden.com/help/datadog-siem/", + image: "../../../../../../../images/integrations/logo-datadog-color.svg", + type: IntegrationType.EVENT, + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Datadog, + }, + ); + } + + // Add Huntress SIEM integration (separate feature flag) + const huntressFeatureEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.EventManagementForHuntress), + ); + + if (huntressFeatureEnabled) { + integrations.push({ + name: OrganizationIntegrationServiceName.Huntress, + linkURL: "https://bitwarden.com/help/huntress-siem/", + image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + type: IntegrationType.EVENT, + description: "huntressEventIntegrationDesc", + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }); + } + + const orgIntegrations = await firstValueFrom( + this.organizationIntegrationService.integrations$.pipe(take(1)), + ); + + const merged = integrations.map((i) => ({ + ...i, + organizationIntegration: orgIntegrations.find((o) => o.serviceName === i.name) ?? null, + })); + + this.state.setIntegrations(merged); + + return true; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts new file mode 100644 index 00000000000..5e7e6a78ba4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts @@ -0,0 +1,22 @@ +import { Injectable, signal } from "@angular/core"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +@Injectable() +export class OrganizationIntegrationsState { + private readonly _integrations = signal([]); + private readonly _organization = signal(undefined); + + // Signals + integrations = this._integrations.asReadonly(); + organization = this._organization.asReadonly(); + + setOrganization(val: Organization | null) { + this._organization.set(val ?? undefined); + } + + setIntegrations(val: Integration[]) { + this._integrations.set(val); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html new file mode 100644 index 00000000000..ca5ed9ee30c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html @@ -0,0 +1,12 @@ +@let integrationsList = integrations(); +
+

{{ "singleSignOn" | i18n }}

+

+ {{ "ssoDescStart" | i18n }} + {{ "singleSignOn" | i18n }} + {{ "ssoDescEnd" | i18n }} +

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts new file mode 100644 index 00000000000..d0d2a1666f2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts @@ -0,0 +1,22 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "single-sign-on", + templateUrl: "single-sign-on.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class SingleSignOnComponent { + integrations = this.state.integrations; + IntegrationType = IntegrationType; + + constructor(private state: OrganizationIntegrationsState) {} +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html new file mode 100644 index 00000000000..a254f334e21 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html @@ -0,0 +1,25 @@ +@let org = organization(); +@let integrationsList = integrations(); + +
+

+ {{ "scimIntegration" | i18n }} +

+

+ {{ "scimIntegrationDescStart" | i18n }} + {{ "scimIntegration" | i18n }} + {{ "scimIntegrationDescEnd" | i18n }} +

+ +
+
+

+ {{ "bwdc" | i18n }} +

+

{{ "bwdcDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts new file mode 100644 index 00000000000..f484674d224 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts @@ -0,0 +1,26 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "user-provisioning", + templateUrl: "user-provisioning.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class UserProvisioningComponent { + organization = this.state.organization; + integrations = this.state.integrations; + + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} 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 4b99c06cdec..284555052dd 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,6 +2,18 @@ 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"; + + expect(isValidRpId(null, origin)).toBe(false); + }); + + it("should not be valid when origin is null", () => { + const rpId = "example.com"; + + expect(isValidRpId(rpId, null)).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"; @@ -25,7 +37,7 @@ describe("validateRpId", () => { it("should not be valid when rpId and origin are both different TLD", () => { const rpId = "bitwarden"; - const origin = "localhost"; + const origin = "https://localhost"; expect(isValidRpId(rpId, origin)).toBe(false); }); @@ -34,14 +46,14 @@ describe("validateRpId", () => { // 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 = "bitwarden"; + const origin = "https://bitwarden"; expect(isValidRpId(rpId, origin)).toBe(false); }); it("should not be valid when rpId and origin are ip-addresses", () => { const rpId = "127.0.0.1"; - const origin = "127.0.0.1"; + const origin = "https://127.0.0.1"; expect(isValidRpId(rpId, origin)).toBe(false); }); @@ -80,4 +92,11 @@ describe("validateRpId", () => { expect(isValidRpId(rpId, origin)).toBe(true); }); + + it("should not be valid for a partial match of a subdomain", () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 67874355908..542beae3435 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,17 +1,78 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { parse } from "tldts"; +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * + * 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://) + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ export function isValidRpId(rpId: string, origin: string) { + if (!rpId || !origin) { + return false; + } + const parsedOrigin = parse(origin, { allowPrivateDomains: true }); const parsedRpId = parse(rpId, { allowPrivateDomains: true }); - return ( - (parsedOrigin.domain == null && - parsedOrigin.hostname == parsedRpId.hostname && - parsedOrigin.hostname == "localhost") || - (parsedOrigin.domain != null && - parsedOrigin.domain == parsedRpId.domain && - parsedOrigin.subdomain.endsWith(parsedRpId.subdomain)) - ); + if (!parsedRpId || !parsedOrigin) { + return false; + } + + // Special case: localhost is always valid when both match + if (parsedRpId.hostname === "localhost" && parsedOrigin.hostname === "localhost") { + return true; + } + + // The origin's scheme must be https. + if (!origin.startsWith("https://")) { + return false; + } + + // Reject IP addresses (both must be domain names) + if (parsedRpId.isIp || parsedOrigin.isIp) { + return false; + } + + // Reject single-label domains (TLDs) unless it's localhost + // This ensures we have proper domains like "example.com" not just "example" + if (rpId !== "localhost" && !rpId.includes(".")) { + return false; + } + + if ( + parsedOrigin.hostname != null && + parsedOrigin.hostname !== "localhost" && + !parsedOrigin.hostname.includes(".") + ) { + return false; + } + + // The registrable domains must match + // This ensures a.example.com and b.example.com share base domain + if (parsedRpId.domain !== parsedOrigin.domain) { + return false; + } + + // Check exact match + if (parsedOrigin.hostname === rpId) { + return true; + } + + // Check if origin is a subdomain of rpId + // This prevents "evilaccounts.example.com" from matching "accounts.example.com" + if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) { + return true; + } + + return false; } diff --git a/libs/common/src/tools/providers.spec.ts b/libs/common/src/tools/providers.spec.ts index d457b1df85e..5953e5ebab2 100644 --- a/libs/common/src/tools/providers.spec.ts +++ b/libs/common/src/tools/providers.spec.ts @@ -4,7 +4,6 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi import { ConfigService } from "../platform/abstractions/config/config.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; -import { SdkService } from "../platform/abstractions/sdk/sdk.service"; import { StateProvider } from "../platform/state"; import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; @@ -21,7 +20,6 @@ describe("SystemServiceProvider", () => { let mockLogger: LogService; let mockEnvironment: MockProxy; let mockConfigService: ConfigService; - let mockSdkService: SdkService; beforeEach(() => { jest.resetAllMocks(); @@ -33,7 +31,6 @@ describe("SystemServiceProvider", () => { mockLogger = mock(); mockEnvironment = mock(); mockConfigService = mock(); - mockSdkService = mock(); }); describe("createSystemServiceProvider", () => { @@ -48,7 +45,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result).toHaveProperty("policy", mockPolicy); @@ -70,7 +66,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result.extension).toBeInstanceOf(ExtensionService); @@ -88,7 +83,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); @@ -108,7 +102,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); @@ -128,7 +121,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result.extension).toBeInstanceOf(ExtensionService); @@ -146,7 +138,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result.policy).toBe(mockPolicy); @@ -163,7 +154,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result.configService).toBe(mockConfigService); @@ -180,7 +170,6 @@ describe("SystemServiceProvider", () => { mockLogger, mockEnvironment, mockConfigService, - mockSdkService, ); expect(result.environment).toBe(mockEnvironment); diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index b1621f19c21..ac42c556042 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -1,10 +1,10 @@ import { LogService } from "@bitwarden/logging"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { StateProvider } from "@bitwarden/state"; import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; import { ConfigService } from "../platform/abstractions/config/config.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; -import { SdkService } from "../platform/abstractions/sdk/sdk.service"; import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; @@ -29,7 +29,7 @@ export type SystemServiceProvider = { readonly environment: PlatformUtilsService; /** SDK Service */ - readonly sdk: SdkService; + readonly sdk?: BitwardenClient; }; /** Constructs a system service provider. */ @@ -41,7 +41,6 @@ export function createSystemServiceProvider( logger: LogService, environment: PlatformUtilsService, configService: ConfigService, - sdk: SdkService, ): SystemServiceProvider { let log: LogProvider; if (environment.isDev()) { @@ -63,6 +62,5 @@ export function createSystemServiceProvider( log, configService, environment, - sdk, }; } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 523a6490fb8..6373a511724 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -934,12 +934,17 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, orgAdmin?: boolean, ): Promise { + // Clear the cache before creating the cipher. The SDK internally updates the encrypted storage + // but the timing of the storage emitting the new values differs across platforms. Clearing the cache after + // `createWithServer` can cause race conditions where the cache is cleared after the + // encrypted storage has already been updated and thus downstream consumers not getting updated data. + await this.clearCache(userId); + const resultCipherView = await this.cipherSdkService.createWithServer( cipherView, userId, orgAdmin, ); - await this.clearCache(userId); return resultCipherView; } @@ -993,13 +998,18 @@ export class CipherService implements CipherServiceAbstraction { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise { + // Clear the cache before updating the cipher. The SDK internally updates the encrypted storage + // but the timing of the storage emitting the new values differs across platforms. Clearing the cache after + // `updateWithServer` can cause race conditions where the cache is cleared after the + // encrypted storage has already been updated and thus downstream consumers not getting updated data. + await this.clearCache(userId); + const resultCipherView = await this.cipherSdkService.updateWithServer( cipher, userId, originalCipherView, orgAdmin, ); - await this.clearCache(userId); return resultCipherView; } diff --git a/libs/eslint/components/no-bwi-class-usage.mjs b/libs/eslint/components/no-bwi-class-usage.mjs index 8260587ce45..6f856646a07 100644 --- a/libs/eslint/components/no-bwi-class-usage.mjs +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -1,6 +1,21 @@ export const errorMessage = "Use component instead of applying 'bwi' classes directly. Example: "; +// Helper classes from libs/angular/src/scss/bwicons/styles/style.scss +// These are utility classes that can be used independently +const ALLOWED_BWI_HELPER_CLASSES = new Set([ + "bwi-fw", // Fixed width + "bwi-sm", // Small + "bwi-lg", // Large + "bwi-2x", // 2x size + "bwi-3x", // 3x size + "bwi-4x", // 4x size + "bwi-spin", // Spin animation + "bwi-ul", // List + "bwi-li", // List item + "bwi-rotate-270", // Rotation +]); + export default { meta: { type: "suggestion", @@ -25,12 +40,23 @@ export default { for (const classAttr of classAttrs) { const classValue = classAttr.value || ""; - // Check if the class value contains 'bwi' or 'bwi-' - // This handles both string literals and template expressions - const hasBwiClass = - typeof classValue === "string" && /\bbwi(?:-[\w-]+)?\b/.test(classValue); + if (typeof classValue !== "string") { + continue; + } - if (hasBwiClass) { + // Extract all bwi classes from the class string + const bwiClassMatches = classValue.match(/\bbwi(?:-[\w-]+)?\b/g); + + if (!bwiClassMatches) { + continue; + } + + // Check if any bwi class is NOT in the allowed helper classes list + const hasDisallowedBwiClass = bwiClassMatches.some( + (cls) => !ALLOWED_BWI_HELPER_CLASSES.has(cls), + ); + + if (hasDisallowedBwiClass) { context.report({ node, message: errorMessage, diff --git a/libs/eslint/components/no-bwi-class-usage.spec.mjs b/libs/eslint/components/no-bwi-class-usage.spec.mjs index abb5ebe3b29..768081ac966 100644 --- a/libs/eslint/components/no-bwi-class-usage.spec.mjs +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -14,10 +14,42 @@ ruleTester.run("no-bwi-class-usage", rule.default, { name: "should allow bit-icon component usage", code: ``, }, + { + name: "should allow bit-icon with bwi-fw helper class", + code: ``, + }, + { + name: "should allow bit-icon with name attribute and bwi-fw helper class", + code: ``, + }, { name: "should allow elements without bwi classes", code: `
`, }, + { + name: "should allow bwi-fw helper class alone", + code: ``, + }, + { + name: "should allow bwi-sm helper class", + code: ``, + }, + { + name: "should allow multiple helper classes together", + code: ``, + }, + { + name: "should allow helper classes with other non-bwi classes", + code: ``, + }, + { + name: "should allow bwi-spin helper class", + code: ``, + }, + { + name: "should allow bwi-rotate-270 helper class", + code: ``, + }, ], invalid: [ { @@ -31,14 +63,19 @@ ruleTester.run("no-bwi-class-usage", rule.default, { errors: [{ message: errorMessage }], }, { - name: "should error on single bwi-* class", + name: "should error on single bwi-* icon class", code: ``, errors: [{ message: errorMessage }], }, { - name: "should error on bwi-fw modifier", + name: "should error on icon classes even with helper classes", code: ``, errors: [{ message: errorMessage }], }, + { + name: "should error on base bwi class alone", + code: ``, + errors: [{ message: errorMessage }], + }, ], }); diff --git a/libs/importer/src/components/importer-providers.ts b/libs/importer/src/components/importer-providers.ts index eb7e58e9259..18c148ebe2e 100644 --- a/libs/importer/src/components/importer-providers.ts +++ b/libs/importer/src/components/importer-providers.ts @@ -13,7 +13,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; @@ -72,7 +71,6 @@ export const ImporterProviders: SafeProvider[] = [ LogService, PlatformUtilsService, ConfigService, - SdkService, ], }), safeProvider({ diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index f41375edd5a..6f2fcbeafcf 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -34,9 +34,11 @@ {{ "fileFormat" | i18n }} - - - + diff --git a/libs/tools/generator/components/src/generator-services.module.ts b/libs/tools/generator/components/src/generator-services.module.ts index 39d0dd298a2..935f7dc2d60 100644 --- a/libs/tools/generator/components/src/generator-services.module.ts +++ b/libs/tools/generator/components/src/generator-services.module.ts @@ -1,10 +1,12 @@ import { NgModule } from "@angular/core"; +import { from, take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -124,7 +126,7 @@ export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken (featureFlag = ff)); const metadata = new providers.GeneratorMetadataProvider( userStateDeps, system, Object.values(BuiltIn), ); + const sdkService = featureFlag ? system.sdk : undefined; const profile = new providers.GeneratorProfileProvider(userStateDeps, system.policy); const generator: providers.GeneratorDependencyProvider = { randomizer: random, client: new RestClient(api, i18n), i18nService: i18n, - sdk: system.sdk, + sdk: sdkService, now: Date.now, }; diff --git a/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts b/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts index 09c7d62b1ad..03be21eeefb 100644 --- a/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts @@ -1,6 +1,3 @@ -import { firstValueFrom } from "rxjs"; - -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { BitwardenClient, PassphraseGeneratorRequest, @@ -23,11 +20,11 @@ export class SdkPasswordRandomizer CredentialGenerator { /** Instantiates the password randomizer - * @param service access to SDK client to call upon password/passphrase generation + * @param client access to SDK client to call upon password/passphrase generation * @param currentTime gets the current datetime in epoch time */ constructor( - private service: SdkService, + private client: BitwardenClient, private currentTime: () => number, ) {} @@ -43,9 +40,8 @@ export class SdkPasswordRandomizer request: GenerateRequest, settings: PasswordGenerationOptions | PassphraseGenerationOptions, ) { - const sdk: BitwardenClient = await firstValueFrom(this.service.client$); if (isPasswordGenerationOptions(settings)) { - const password = await sdk.generator().password(convertPasswordRequest(settings)); + const password = await this.client.generator().password(convertPasswordRequest(settings)); return new GeneratedCredential( password, @@ -55,7 +51,9 @@ export class SdkPasswordRandomizer request.website, ); } else if (isPassphraseGenerationOptions(settings)) { - const passphrase = await sdk.generator().passphrase(convertPassphraseRequest(settings)); + const passphrase = await this.client + .generator() + .passphrase(convertPassphraseRequest(settings)); return new GeneratedCredential( passphrase, diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index 015cc25a8ec..bdf021c50f3 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { SdkPasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { PassphrasePolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { PassphraseGenerationOptions } from "../../types"; @@ -22,6 +22,16 @@ describe("password - eff words generator metadata", () => { }); }); + describe("engine.create", () => { + const nonSdkDependencyProvider = mock(); + nonSdkDependencyProvider.sdk = undefined; + it("returns a password randomizer", () => { + expect(effPassphrase.engine.create(nonSdkDependencyProvider)).toBeInstanceOf( + PasswordRandomizer, + ); + }); + }); + describe("profiles[account]", () => { let accountProfile: CoreProfileMetadata | null = null; beforeEach(() => { diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts index d6d78c83293..fc96ce46c2b 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -3,7 +3,7 @@ import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { SdkPasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; @@ -30,6 +30,9 @@ const passphrase: GeneratorMetadata = { create( dependencies: GeneratorDependencyProvider, ): CredentialGenerator { + if (dependencies.sdk == undefined) { + return new PasswordRandomizer(dependencies.randomizer, dependencies.now); + } return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now); }, }, diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index d066b9f1597..9efd5350c21 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { SdkPasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { PasswordGenerationOptions } from "../../types"; @@ -22,6 +22,14 @@ describe("password - characters generator metadata", () => { }); }); + describe("engine.create", () => { + const nonSdkDependencyProvider = mock(); + nonSdkDependencyProvider.sdk = undefined; + it("returns a password randomizer", () => { + expect(password.engine.create(nonSdkDependencyProvider)).toBeInstanceOf(PasswordRandomizer); + }); + }); + describe("profiles[account]", () => { let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts index d25ea1e8f46..721be8dc3f0 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -3,7 +3,7 @@ import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { deepFreeze } from "@bitwarden/common/tools/util"; -import { SdkPasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; @@ -30,6 +30,9 @@ const password: GeneratorMetadata = deepFreeze({ create( dependencies: GeneratorDependencyProvider, ): CredentialGenerator { + if (dependencies.sdk == undefined) { + return new PasswordRandomizer(dependencies.randomizer, dependencies.now); + } return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now); }, }, diff --git a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts index 8700bbc8a24..a6dbbeaa537 100644 --- a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts @@ -1,6 +1,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { Randomizer } from "../abstractions"; @@ -10,6 +10,6 @@ export type GeneratorDependencyProvider = { // FIXME: introduce `I18nKeyOrLiteral` into forwarder // structures and remove this dependency i18nService: I18nService; - sdk: SdkService; + sdk?: BitwardenClient; now: () => number; }; diff --git a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts index f79bb986325..39ff74ad901 100644 --- a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts @@ -5,6 +5,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; import { @@ -95,6 +96,8 @@ const SomePolicyService = mock(); const SomeExtensionService = mock(); +const SomeConfigService = mock; + const SomeSdkService = mock; const ApplicationProvider = { @@ -107,6 +110,9 @@ const ApplicationProvider = { /** Event monitoring and diagnostic interfaces */ log: disabledSemanticLoggerProvider, + /** Feature flag retrieval */ + configService: SomeConfigService, + /** SDK access for password generation */ sdk: SomeSdkService, } as unknown as SystemServiceProvider; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index fb9b82c44e5..7b966bb0345 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -7,11 +7,13 @@ {{ "sendTypeText" | i18n }} - +
{{ "sendTypeFile" | i18n }}
+ {{ "popOutNewWindow" | i18n }} +
diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.html b/libs/tools/send/send-ui/src/send-search/send-search.component.html index 7cf154c0ee8..fbbe436d158 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.html +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.html @@ -1,7 +1 @@ - - + diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.ts b/libs/tools/send/send-ui/src/send-search/send-search.component.ts index 02cb5ef2eda..03eaf9b3430 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.ts +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.ts @@ -1,50 +1,55 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ChangeDetectionStrategy, Component, inject, model } from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, Subscription, debounceTime, filter } from "rxjs"; +import { debounceTime, filter } from "rxjs"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SearchModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { SendItemsService } from "../services/send-items.service"; const SearchTextDebounceInterval = 200; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Search component for filtering Send items. + * + * Provides a search input that filters the Send list with debounced updates. + * Syncs with the service's latest search text to maintain state across navigation. + */ @Component({ - imports: [CommonModule, SearchModule, JslibModule, FormsModule], selector: "tools-send-search", templateUrl: "send-search.component.html", + imports: [FormsModule, I18nPipe, SearchModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendSearchComponent { - searchText: string = ""; + private sendListItemService = inject(SendItemsService); - private searchText$ = new Subject(); + /** The current search text entered by the user. */ + protected readonly searchText = model(""); - constructor(private sendListItemService: SendItemsService) { + constructor() { this.subscribeToLatestSearchText(); this.subscribeToApplyFilter(); } - onSearchTextChanged() { - this.searchText$.next(this.searchText); - } - - subscribeToLatestSearchText(): Subscription { - return this.sendListItemService.latestSearchText$ + private subscribeToLatestSearchText(): void { + this.sendListItemService.latestSearchText$ .pipe( takeUntilDestroyed(), filter((data) => !!data), ) .subscribe((text) => { - this.searchText = text; + this.searchText.set(text); }); } - subscribeToApplyFilter(): Subscription { - return this.searchText$ + /** + * Applies the search filter to the Send list with a debounce delay. + * This prevents excessive filtering while the user is still typing. + */ + private subscribeToApplyFilter(): void { + toObservable(this.searchText) .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) .subscribe((data) => { this.sendListItemService.applyFilter(data); diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index cc2fca2c41c..1c235415cae 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -15,10 +15,10 @@