1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into billing/pm-29602/build-upgrade-dialogs

This commit is contained in:
Stephon Brown
2026-02-05 17:30:49 -05:00
85 changed files with 1824 additions and 876 deletions

View File

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

View File

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

View File

@@ -1110,7 +1110,6 @@ export default class MainBackground {
this.logService,
this.platformUtilsService,
this.configService,
this.sdkService,
),
);

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
<bit-callout
type="warning"
icon="bwi-external-link bwi-rotate-270 bwi-fw"
title="{{ 'sendFileCalloutHeader' | i18n }}"
(click)="popOutWindow()"
*ngIf="showFilePopoutMessage"
>
<div *ngIf="showChromiumFileWarning">{{ "sendLinuxChromiumFileWarning" | i18n }}</div>
<div *ngIf="showFirefoxFileWarning">{{ "sendFirefoxFileWarning" | i18n }}</div>
<div *ngIf="showSafariFileWarning">{{ "sendSafariFileWarning" | i18n }}</div>
</bit-callout>

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,6 @@
>
</tools-send-form>
<send-file-popout-dialog-container [config]="config"></send-file-popout-dialog-container>
<popup-footer slot="footer">
<button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}

View File

@@ -33,7 +33,6 @@ import { PopupBackBrowserDirective } from "../../../../platform/popup/layout/pop
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { SendFilePopoutDialogContainerComponent } from "../send-file-popout-dialog/send-file-popout-dialog-container.component";
/**
* Helper class to parse query parameters for the AddEdit route.
@@ -81,7 +80,6 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
SendFilePopoutDialogContainerComponent,
SendFormModule,
AsyncActionsModule,
PopupBackBrowserDirective,

View File

@@ -1,41 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, input, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { SendFormConfig } from "@bitwarden/send-ui";
import { FilePopoutUtilsService } from "../../services/file-popout-utils.service";
import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "send-file-popout-dialog-container",
templateUrl: "./send-file-popout-dialog-container.component.html",
imports: [JslibModule, CommonModule],
})
export class SendFilePopoutDialogContainerComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
config = input.required<SendFormConfig>();
constructor(
private dialogService: DialogService,
private filePopoutUtilsService: FilePopoutUtilsService,
) {}
ngOnInit() {
if (
this.config().sendType === SendType.File &&
this.config().mode === "add" &&
this.filePopoutUtilsService.showFilePopoutMessage(window)
) {
this.dialogService.open(SendFilePopoutDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
});
}
}
}

View File

@@ -1,20 +0,0 @@
<bit-simple-dialog dialogSize="default">
<div bitDialogIcon>
<i class="bwi bwi-info-circle bwi-2x tw-text-info" aria-hidden="true"></i>
</div>
<ng-container bitDialogContent>
<div bitTypography="h3">
{{ "sendFilePopoutDialogText" | i18n }}
</div>
<div bitTypography="body1">{{ "sendFilePopoutDialogDesc" | i18n }}</div>
</ng-container>
<ng-container bitDialogFooter>
<button buttonType="primary" bitButton type="button" (click)="popOutWindow()">
{{ "popOut" | i18n }}
<i class="bwi bwi-popout tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" type="button" (click)="close()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

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

View File

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

View File

@@ -4,14 +4,15 @@
type="button"
(click)="openAttachments()"
[disabled]="parentFormDisabled"
[title]="'popOutNewWindow' | i18n"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<ng-container slot="end">
<i class="bwi bwi-popout" aria-hidden="true" *ngIf="openAttachmentsInPopout"></i>
<i class="bwi bwi-angle-right" aria-hidden="true" *ngIf="!openAttachmentsInPopout"></i>
<span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span>
<i class="bwi bwi-popout tw-text-muted" aria-hidden="true"></i>
</ng-container>
</button>
</bit-item>

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
</a>
</bit-item>
<bit-item>
<button type="button" bit-item-content (click)="import()">
<button type="button" bit-item-content (click)="import()" [title]="'popOutNewWindow' | i18n">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-2">
<p>{{ "importNoun" | i18n }}</p>
<span
@@ -25,7 +25,10 @@
1
</span>
</div>
<i slot="end" class="bwi bwi-popout" aria-hidden="true"></i>
<ng-container slot="end">
<span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span>
<i class="bwi bwi-popout tw-text-muted" aria-hidden="true"></i>
</ng-container>
</button>
</bit-item>
<bit-item>

View File

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

View File

@@ -928,7 +928,6 @@ export class ServiceContainer {
this.logService,
this.platformUtilsService,
this.configService,
this.sdkService,
),
);

View File

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

View File

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

View File

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

View File

@@ -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. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -80,6 +80,7 @@ export class VaultFilterComponent implements OnInit {
protected readonly showCollectionsFilter = computed<boolean>(() => {
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$(

View File

@@ -30,7 +30,7 @@
></app-org-vault-header>
}
<div class="tw-flex tw-flex-row">
<div class="tw-flex tw-flex-row tw-flex-1">
@let hideVaultFilters = hideVaultFilter$ | async;
@if (!hideVaultFilters) {
<div class="tw-w-1/4 tw-mr-5">
@@ -43,7 +43,9 @@
</div>
}
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<div
[class]="(hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4') + ' tw-flex tw-flex-col tw-min-h-0'"
>
@if (showAddAccessToggle && activeFilter.selectedCollectionNode) {
<bit-toggle-group
[selected]="addAccessStatus$ | async"
@@ -68,6 +70,7 @@
@if (filter) {
<app-vault-items
class="tw-flex-1 tw-min-h-0"
#vaultItems
[ciphers]="ciphers$ | async"
[collections]="collections$ | async"

View File

@@ -1318,7 +1318,7 @@ export class VaultComponent implements OnInit, OnDestroy {
selectedCollection?.node.id === c.id
) {
void this.router.navigate([], {
queryParams: { collectionId: selectedCollection.parent.node.id ?? null },
queryParams: { collectionId: selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});

View File

@@ -14,7 +14,7 @@
<bit-callout
type="danger"
*ngIf="nonCompliantMembers"
*ngIf="nonCompliantMembers && !isRevoking"
title="{{ 'nonCompliantMembersTitle' | i18n }}"
>
{{ "nonCompliantMembersError" | i18n }}

View File

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

View File

@@ -136,7 +136,7 @@
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
</button>
<button
type="button"

View File

@@ -597,6 +597,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
get selectedInvitedCount(): number {
return this.dataSource
.getCheckedUsers()
.filter((member) => member.status === this.userStatusType.Invited).length;
}
get isSingleInvite(): boolean {
return this.selectedInvitedCount === 1;
}
exportMembers = () => {
const result = this.memberExportService.getMemberExport(this.dataSource.data);
if (result.success) {

View File

@@ -3,6 +3,7 @@
@let bulkActions = bulkMenuOptions$ | async;
@let showConfirmBanner = showConfirmBanner$ | async;
@let isProcessing = this.isProcessing();
@let isSingleInvite = isSingleInvite$ | async;
@if (organization && dataSource) {
<app-organization-free-trial-warning
@@ -151,7 +152,7 @@
(click)="isProcessing ? null : bulkReinvite(organization)"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
</button>
}
@if (bulkActions.showBulkConfirmUsers) {

View File

@@ -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<boolean> = computed(

View File

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

View File

@@ -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)"
>
<div class="tw-truncate">
<i *ngIf="icon" class="bwi {{ icon }}" aria-hidden="true"></i>
{{ title || (routeData.titleId | i18n) }}
<i *ngIf="icon" class="bwi {{ icon() }}" aria-hidden="true"></i>
{{ title() || (routeData.titleId | i18n) }}
</div>
<div><ng-content select="[slot=title-suffix]"></ng-content></div>
</h1>

View File

@@ -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<string | undefined> = 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<string | undefined> = input();
protected routeData$: Observable<{ titleId: string }>;
protected account$: Observable<User & { id: UserId }>;
protected account$: Observable<(User & { id: UserId }) | null>;
protected canLock$: Observable<boolean>;
protected selfHosted: boolean;
protected hostname = location.hostname;

View File

@@ -83,7 +83,7 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start tw-flex-1"
*ngIf="isEmpty && !performingInitialLoad"
>
<bit-no-items [icon]="(emptyState$ | async)?.icon">

View File

@@ -89,7 +89,7 @@
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
</button>
<button
type="button"

View File

@@ -333,4 +333,14 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
return { success: false, error: error.message };
}
};
get selectedInvitedCount(): number {
return this.dataSource
.getCheckedUsers()
.filter((member) => member.status === this.userStatusType.Invited).length;
}
get isSingleInvite(): boolean {
return this.selectedInvitedCount === 1;
}
}

View File

@@ -3,6 +3,7 @@
@let showConfirmBanner = showConfirmBanner$ | async;
@let dataSource = this.dataSource();
@let isProcessing = this.isProcessing();
@let isSingleInvite = isSingleInvite$ | async;
<app-header>
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
@@ -92,7 +93,7 @@
(click)="isProcessing ? null : bulkReinvite(providerId)"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
</button>
}
@if (bulkMenuOptions.showBulkConfirmUsers) {

View File

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

View File

@@ -123,7 +123,9 @@ const routes: Routes = [
},
{
path: "billing",
canActivate: [providerPermissionsGuard()],
canActivate: [
providerPermissionsGuard((provider: Provider) => provider.isProviderAdmin),
],
children: [
{
path: "",

View File

@@ -6,7 +6,7 @@
<div class="tw-flex tw-mb-4 tw-gap-4 tw-items-center">
<bit-search
[placeholder]="'searchApps' | i18n"
class="tw-min-w-96"
class="tw-min-w-96 tw-w-1/2"
[formControl]="searchControl"
></bit-search>

View File

@@ -0,0 +1,11 @@
@let integrationsList = integrations();
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "deviceManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
></app-integration-grid>
</section>

View File

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

View File

@@ -0,0 +1,11 @@
@let integrationsList = integrations();
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "eventManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
></app-integration-grid>
</section>

View File

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

View File

@@ -1,82 +1,18 @@
<app-header> </app-header>
@let org = organization();
@let organization = organization$ | async;
<app-header>
@if (org) {
<bit-tab-nav-bar slot="tabs">
<bit-tab-link route="single-sign-on">{{ "singleSignOn" | i18n }}</bit-tab-link>
@if (org.useScim || org.useDirectory) {
<bit-tab-link route="user-provisioning">{{ "userProvisioning" | i18n }}</bit-tab-link>
}
@if (org.useEvents) {
<bit-tab-link route="event-management">{{ "eventManagement" | i18n }}</bit-tab-link>
}
<bit-tab-link route="device-management">{{ "deviceManagement" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
}
</app-header>
@if (organization) {
<bit-tab-group [(selectedIndex)]="tabIndex">
@if (organization?.useSso) {
<bit-tab [label]="'singleSignOn' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
<p bitTypography="body1">
{{ "ssoDescStart" | i18n }}
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{
"singleSignOn" | i18n
}}</a>
{{ "ssoDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
></app-integration-grid>
</section>
</bit-tab>
}
@if (organization?.useScim || organization?.useDirectory) {
<bit-tab [label]="'userProvisioning' | i18n">
@if (organization?.useScim) {
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "scimIntegration" | i18n }}
</h2>
<p bitTypography="body1">
{{ "scimIntegrationDescStart" | i18n }}
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
{{ "scimIntegrationDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
></app-integration-grid>
</section>
}
@if (organization?.useDirectory) {
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "bwdc" | i18n }}
</h2>
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
></app-integration-grid>
</section>
}
</bit-tab>
}
@if (organization?.useEvents) {
<bit-tab [label]="'eventManagement' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "eventManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
></app-integration-grid>
</section>
</bit-tab>
}
<bit-tab [label]="'deviceManagement' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "deviceManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
></app-integration-grid>
</section>
</bit-tab>
</bit-tab-group>
}
<router-outlet></router-outlet>

View File

@@ -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<Organization> = new Observable<Organization>();
isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
isEventManagementForHuntressEnabled: boolean = false;
private destroy$ = new Subject<void>();
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 {

View File

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

View File

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

View File

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

View File

@@ -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<boolean> {
constructor(
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private organizationIntegrationService: OrganizationIntegrationService,
private state: OrganizationIntegrationsState,
) {}
async resolve(route: ActivatedRouteSnapshot): Promise<boolean> {
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;
}
}

View File

@@ -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<Integration[]>([]);
private readonly _organization = signal<Organization | undefined>(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);
}
}

View File

@@ -0,0 +1,12 @@
@let integrationsList = integrations();
<section class="tw-mb-9">
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
<p bitTypography="body1">
{{ "ssoDescStart" | i18n }}
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
{{ "ssoDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
></app-integration-grid>
</section>

View File

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

View File

@@ -0,0 +1,25 @@
@let org = organization();
@let integrationsList = integrations();
<section class="tw-mb-9" *ngIf="org?.useScim">
<h2 bitTypography="h2">
{{ "scimIntegration" | i18n }}
</h2>
<p bitTypography="body1">
{{ "scimIntegrationDescStart" | i18n }}
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
{{ "scimIntegrationDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
></app-integration-grid>
</section>
<section class="tw-mb-9" *ngIf="org?.useDirectory">
<h2 bitTypography="h2">
{{ "bwdc" | i18n }}
</h2>
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
></app-integration-grid>
</section>

View File

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

View File

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

View File

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

View File

@@ -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<PlatformUtilsService>;
let mockConfigService: ConfigService;
let mockSdkService: SdkService;
beforeEach(() => {
jest.resetAllMocks();
@@ -33,7 +31,6 @@ describe("SystemServiceProvider", () => {
mockLogger = mock<LogService>();
mockEnvironment = mock<PlatformUtilsService>();
mockConfigService = mock<ConfigService>();
mockSdkService = mock<SdkService>();
});
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);

View File

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

View File

@@ -934,12 +934,17 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView | void> {
// 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<CipherView> {
// 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;
}

View File

@@ -1,6 +1,21 @@
export const errorMessage =
"Use <bit-icon> component instead of applying 'bwi' classes directly. Example: <bit-icon name=\"bwi-lock\"></bit-icon>";
// 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,

View File

@@ -14,10 +14,42 @@ ruleTester.run("no-bwi-class-usage", rule.default, {
name: "should allow bit-icon component usage",
code: `<bit-icon icon="bwi-lock"></bit-icon>`,
},
{
name: "should allow bit-icon with bwi-fw helper class",
code: `<bit-icon icon="bwi-lock" class="bwi-fw"></bit-icon>`,
},
{
name: "should allow bit-icon with name attribute and bwi-fw helper class",
code: `<bit-icon name="bwi-angle-down" class="bwi-fw"/>`,
},
{
name: "should allow elements without bwi classes",
code: `<div class="tw-flex tw-p-4"></div>`,
},
{
name: "should allow bwi-fw helper class alone",
code: `<i class="bwi-fw"></i>`,
},
{
name: "should allow bwi-sm helper class",
code: `<i class="bwi-sm"></i>`,
},
{
name: "should allow multiple helper classes together",
code: `<i class="bwi-fw bwi-sm"></i>`,
},
{
name: "should allow helper classes with other non-bwi classes",
code: `<i class="tw-flex bwi-fw bwi-lg tw-p-2"></i>`,
},
{
name: "should allow bwi-spin helper class",
code: `<i class="bwi-spin"></i>`,
},
{
name: "should allow bwi-rotate-270 helper class",
code: `<i class="bwi-rotate-270"></i>`,
},
],
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: `<i class="bwi-lock"></i>`,
errors: [{ message: errorMessage }],
},
{
name: "should error on bwi-fw modifier",
name: "should error on icon classes even with helper classes",
code: `<i class="bwi bwi-lock bwi-fw"></i>`,
errors: [{ message: errorMessage }],
},
{
name: "should error on base bwi class alone",
code: `<i class="bwi"></i>`,
errors: [{ message: errorMessage }],
},
],
});

View File

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

View File

@@ -34,9 +34,11 @@
<bit-form-field>
<bit-label>{{ "fileFormat" | i18n }}</bit-label>
<bit-select formControlName="format">
<bit-option *ngFor="let f of formatOptions$ | async" [value]="f.format" [label]="f.name" />
</bit-select>
<select bitInput formControlName="format">
@for (f of formatOptions$ | async; track f.format) {
<option [value]="f.format">{{ f.name }}</option>
}
</select>
</bit-form-field>
<ng-container *ngIf="format === 'encrypted_json'">

View File

@@ -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<SystemServiceProvi
}),
safeProvider({
provide: GENERATOR_SERVICE_PROVIDER,
useFactory: async (
useFactory: (
system: SystemServiceProvider,
random: Randomizer,
encryptor: LegacyEncryptorProvider,
@@ -139,19 +141,25 @@ export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvi
now: Date.now,
} satisfies UserStateSubjectDependencyProvider;
const featureFlagObs$ = from(
system.configService.getFeatureFlag(FeatureFlag.UseSdkPasswordGenerators),
);
let featureFlag: boolean = false;
featureFlagObs$.pipe(take(1)).subscribe((ff) => (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,
};

View File

@@ -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<PasswordGenerationOptions>
{
/** 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,

View File

@@ -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<GeneratorDependencyProvider>();
nonSdkDependencyProvider.sdk = undefined;
it("returns a password randomizer", () => {
expect(effPassphrase.engine.create(nonSdkDependencyProvider)).toBeInstanceOf(
PasswordRandomizer,
);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> | null = null;
beforeEach(() => {

View File

@@ -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<PassphraseGenerationOptions> = {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PassphraseGenerationOptions> {
if (dependencies.sdk == undefined) {
return new PasswordRandomizer(dependencies.randomizer, dependencies.now);
}
return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now);
},
},

View File

@@ -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<GeneratorDependencyProvider>();
nonSdkDependencyProvider.sdk = undefined;
it("returns a password randomizer", () => {
expect(password.engine.create(nonSdkDependencyProvider)).toBeInstanceOf(PasswordRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = null!;
beforeEach(() => {

View File

@@ -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<PasswordGeneratorSettings> = deepFreeze({
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PasswordGeneratorSettings> {
if (dependencies.sdk == undefined) {
return new PasswordRandomizer(dependencies.randomizer, dependencies.now);
}
return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now);
},
},

View File

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

View File

@@ -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<PolicyService>();
const SomeExtensionService = mock<ExtensionService>();
const SomeConfigService = mock<ConfigService>;
const SomeSdkService = mock<BitwardenClient>;
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;

View File

@@ -7,11 +7,13 @@
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
{{ "sendTypeText" | i18n }}
</a>
<a bitMenuItem (click)="sendFileClick()">
<a bitMenuItem (click)="sendFileClick()" [title]="'popOutNewWindow' | i18n">
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
{{ "sendTypeFile" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span>
<i class="bwi bwi-popout tw-text-muted" slot="end" aria-hidden="true"></i>
</a>
</bit-menu>

View File

@@ -1,7 +1 @@
<bit-search
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
>
</bit-search>
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText" appAutofocus />

View File

@@ -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<string>();
/** 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);

View File

@@ -15,10 +15,10 @@
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
<i class="bwi bwi-fw bwi-lg bwi-file tw-text-muted"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
<i class="bwi bwi-fw bwi-lg bwi-file-text tw-text-muted"></i>
}
</span>
<button type="button" bitLink>
@@ -85,6 +85,7 @@
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
size="small"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"

2
package-lock.json generated
View File

@@ -277,7 +277,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2026.1.0",
"version": "2026.2.0",
"hasInstallScript": true,
"license": "GPL-3.0"
},