1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 02:44:01 +00:00

Merge branch 'main' into desktop/pm-18769/migrate-vault-filters

This commit is contained in:
Leslie Xiong
2025-12-15 12:08:06 -05:00
committed by GitHub
158 changed files with 2749 additions and 1964 deletions

View File

@@ -154,7 +154,6 @@
"@types/glob",
"@types/lowdb",
"@types/node",
"@types/node-forge",
"@types/node-ipc",
"@yao-pkg/pkg",
"anyhow",
@@ -192,7 +191,6 @@
"napi",
"napi-build",
"napi-derive",
"node-forge",
"node-ipc",
"nx",
"oo7",
@@ -415,14 +413,16 @@
},
{
matchPackageNames: [
"@types/node-forge",
"aes",
"big-integer",
"cbc",
"linux-keyutils",
"memsec",
"node-forge",
"rsa",
"russh-cryptovec",
"sha2",
"memsec",
"linux-keyutils",
],
description: "Key Management owned dependencies",
commitMessagePrefix: "[deps] KM:",

View File

@@ -583,7 +583,9 @@ jobs:
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
env:
MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }}
run: node build.js cross-platform "$env:MODE"
- name: Build
run: npm run build
@@ -846,7 +848,9 @@ jobs:
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
env:
MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }}
run: node build.js cross-platform "$env:MODE"
- name: Build
run: npm run build
@@ -1202,7 +1206,9 @@ jobs:
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
env:
MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }}
run: node build.js cross-platform "$MODE"
- name: Build application (dev)
run: npm run build
@@ -1424,7 +1430,9 @@ jobs:
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
env:
MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }}
run: node build.js cross-platform "$MODE"
- name: Build
if: steps.build-cache.outputs.cache-hit != 'true'
@@ -1705,7 +1713,9 @@ jobs:
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
env:
MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }}
run: node build.js cross-platform "$MODE"
- name: Build
if: steps.build-cache.outputs.cache-hit != 'true'

View File

@@ -98,6 +98,14 @@ jobs:
working-directory: apps/desktop/artifacts
run: mv "Bitwarden-${PKG_VERSION}-universal.pkg" "Bitwarden-${PKG_VERSION}-universal.pkg.archive"
- name: Rename .tar.gz to include version
env:
PKG_VERSION: ${{ steps.version.outputs.version }}
working-directory: apps/desktop/artifacts
run: |
mv "bitwarden_desktop_x64.tar.gz" "bitwarden_${PKG_VERSION}_x64.tar.gz"
mv "bitwarden_desktop_arm64.tar.gz" "bitwarden_${PKG_VERSION}_arm64.tar.gz"
- name: Create Release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }}

View File

@@ -436,8 +436,8 @@
"sync": {
"message": "Sync"
},
"syncVaultNow": {
"message": "Sync vault now"
"syncNow": {
"message": "Sync now"
},
"lastSync": {
"message": "Last sync:"
@@ -455,9 +455,6 @@
"bitWebVaultApp": {
"message": "Bitwarden web app"
},
"importItems": {
"message": "Import items"
},
"select": {
"message": "Select"
},
@@ -1325,8 +1322,11 @@
"exportFrom": {
"message": "Export from"
},
"exportVault": {
"message": "Export vault"
"export": {
"message": "Export"
},
"import": {
"message": "Import"
},
"fileFormat": {
"message": "File format"
@@ -4215,10 +4215,6 @@
"ignore": {
"message": "Ignore"
},
"importData": {
"message": "Import data",
"description": "Used for the header of the import dialog, the import button and within the file-password-prompt"
},
"importError": {
"message": "Import error"
},
@@ -4805,6 +4801,15 @@
"accountSecurity": {
"message": "Account security"
},
"phishingBlocker": {
"message": "Phishing Blocker"
},
"enablePhishingDetection": {
"message": "Phishing detection"
},
"enablePhishingDetectionDesc": {
"message": "Display warning before accessing suspected phishing sites"
},
"notifications": {
"message": "Notifications"
},

View File

@@ -15,6 +15,7 @@ import {
} from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { AccountSwitcherService } from "./account-switcher.service";
@@ -71,11 +72,10 @@ describe("AccountSwitcherService", () => {
describe("availableAccounts$", () => {
it("should return all logged in accounts and an add account option when accounts are less than 5", async () => {
const accountInfo: AccountInfo = {
const accountInfo = mockAccountInfoWith({
name: "Test User 1",
email: "test1@email.com",
emailVerified: true,
};
});
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
accountsSubject.next({ ["1" as UserId]: accountInfo, ["2" as UserId]: accountInfo });
@@ -109,11 +109,10 @@ describe("AccountSwitcherService", () => {
const seedAccounts: Record<UserId, AccountInfo> = {};
const seedStatuses: Record<UserId, AuthenticationStatus> = {};
for (let i = 0; i < numberOfAccounts; i++) {
seedAccounts[`${i}` as UserId] = {
seedAccounts[`${i}` as UserId] = mockAccountInfoWith({
email: `test${i}@email.com`,
emailVerified: true,
name: "Test User ${i}",
};
});
seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked;
}
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
@@ -133,11 +132,10 @@ describe("AccountSwitcherService", () => {
);
it("excludes logged out accounts", async () => {
const user1AccountInfo: AccountInfo = {
const user1AccountInfo = mockAccountInfoWith({
name: "Test User 1",
email: "",
emailVerified: true,
};
});
accountsSubject.next({ ["1" as UserId]: user1AccountInfo });
authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut });
accountsSubject.next({

View File

@@ -128,6 +128,20 @@
</bit-item>
</bit-section>
<bit-section *ngIf="phishingDetectionAvailable$ | async">
<bit-section-header>
<h2 bitTypography="h6">{{ "phishingBlocker" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-switch formControlName="enablePhishingDetection" id="phishingDetectionAction">
<bit-label for="phishingDetectionAction">{{
"enablePhishingDetection" | i18n
}}</bit-label>
<bit-hint>{{ "enablePhishingDetectionDesc" | i18n }}</bit-hint>
</bit-switch>
</bit-card>
</bit-section>
<bit-section disableMargin>
<bit-section-header>
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>

View File

@@ -3,9 +3,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { firstValueFrom, of, BehaviorSubject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { NudgesService } from "@bitwarden/angular/vault";
import { LockService } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -14,12 +15,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -27,12 +31,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -54,18 +58,27 @@ describe("AccountSecurityComponent", () => {
let component: AccountSecurityComponent;
let fixture: ComponentFixture<AccountSecurityComponent>;
const mockUserId = Utils.newGuid() as UserId;
const mockUserId = newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const apiService = mock<ApiService>();
const billingService = mock<BillingAccountProfileStateService>();
const biometricStateService = mock<BiometricStateService>();
const policyService = mock<PolicyService>();
const pinServiceAbstraction = mock<PinServiceAbstraction>();
const keyService = mock<KeyService>();
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const lockService = mock<LockService>();
const configService = mock<ConfigService>();
const dialogService = mock<DialogService>();
const keyService = mock<KeyService>();
const lockService = mock<LockService>();
const policyService = mock<PolicyService>();
const phishingDetectionSettingsService = mock<PhishingDetectionSettingsServiceAbstraction>();
const pinServiceAbstraction = mock<PinServiceAbstraction>();
const platformUtilsService = mock<PlatformUtilsService>();
const validationService = mock<ValidationService>();
const vaultNudgesService = mock<NudgesService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
// Mock subjects to control the phishing detection observables
let phishingAvailableSubject: BehaviorSubject<boolean>;
let phishingEnabledSubject: BehaviorSubject<boolean>;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -73,29 +86,38 @@ describe("AccountSecurityComponent", () => {
{ provide: AccountService, useValue: accountService },
{ provide: AccountSecurityComponent, useValue: mock<AccountSecurityComponent>() },
{ provide: ActivatedRoute, useValue: mock<ActivatedRoute>() },
{ provide: ApiService, useValue: apiService },
{
provide: BillingAccountProfileStateService,
useValue: billingService,
},
{ provide: BiometricsService, useValue: mock<BiometricsService>() },
{ provide: BiometricStateService, useValue: biometricStateService },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: KeyService, useValue: keyService },
{ provide: LockService, useValue: lockService },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: NudgesService, useValue: vaultNudgesService },
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
{
provide: PhishingDetectionSettingsServiceAbstraction,
useValue: phishingDetectionSettingsService,
},
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: ApiService, useValue: mock<ApiService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
{ provide: ConfigService, useValue: configService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
],
})
.overrideComponent(AccountSecurityComponent, {
@@ -110,10 +132,13 @@ describe("AccountSecurityComponent", () => {
})
.compileComponents();
fixture = TestBed.createComponent(AccountSecurityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
apiService.getProfile.mockResolvedValue(
mock<ProfileResponse>({
id: mockUserId,
creationDate: new Date().toISOString(),
}),
);
vaultNudgesService.showNudgeSpotlight$.mockReturnValue(of(false));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
of(VaultTimeoutStringType.OnLocked),
);
@@ -123,8 +148,25 @@ describe("AccountSecurityComponent", () => {
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
vaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(of([]));
biometricStateService.promptAutomatically$ = of(false);
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
configService.getFeatureFlag$.mockReturnValue(of(false));
billingService.hasPremiumPersonally$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([null]));
// Mock readonly observables for phishing detection using BehaviorSubjects so
// tests can push different values after component creation.
phishingAvailableSubject = new BehaviorSubject<boolean>(true);
phishingEnabledSubject = new BehaviorSubject<boolean>(true);
(phishingDetectionSettingsService.available$ as any) = phishingAvailableSubject.asObservable();
(phishingDetectionSettingsService.enabled$ as any) = phishingEnabledSubject.asObservable();
fixture = TestBed.createComponent(AccountSecurityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
@@ -233,6 +275,59 @@ describe("AccountSecurityComponent", () => {
expect(pinInputElement).toBeNull();
});
describe("phishing detection UI and setting", () => {
it("updates phishing detection setting when form value changes", async () => {
policyService.policiesByType$.mockReturnValue(of([null]));
phishingAvailableSubject.next(true);
phishingEnabledSubject.next(true);
// Init component
await component.ngOnInit();
fixture.detectChanges();
// Initial form value should match enabled$ observable defaulting to true
expect(component.form.controls.enablePhishingDetection.value).toBe(true);
// Change the form value to false
component.form.controls.enablePhishingDetection.setValue(false);
fixture.detectChanges();
// Wait briefly to allow any debounced or async valueChanges handlers to run
// fixture.whenStable() does not work here
await new Promise((resolve) => setTimeout(resolve, 0));
expect(phishingDetectionSettingsService.setEnabled).toHaveBeenCalledWith(mockUserId, false);
});
it("shows phishing detection element when available$ is true", async () => {
policyService.policiesByType$.mockReturnValue(of([null]));
phishingAvailableSubject.next(true);
phishingEnabledSubject.next(true);
await component.ngOnInit();
fixture.detectChanges();
const phishingDetectionElement = fixture.debugElement.query(
By.css("#phishingDetectionAction"),
);
expect(phishingDetectionElement).not.toBeNull();
});
it("hides phishing detection element when available$ is false", async () => {
policyService.policiesByType$.mockReturnValue(of([null]));
phishingAvailableSubject.next(false);
phishingEnabledSubject.next(true);
await component.ngOnInit();
fixture.detectChanges();
const phishingDetectionElement = fixture.debugElement.query(
By.css("#phishingDetectionAction"),
);
expect(phishingDetectionElement).toBeNull();
});
});
describe("updateBiometric", () => {
let browserApiSpy: jest.SpyInstance;

View File

@@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
@@ -62,6 +63,7 @@ import {
SelectModule,
TypographyModule,
ToastService,
SwitchComponent,
} from "@bitwarden/components";
import {
KeyService,
@@ -110,6 +112,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
SpotlightComponent,
TypographyModule,
SessionTimeoutInputLegacyComponent,
SwitchComponent,
],
})
export class AccountSecurityComponent implements OnInit, OnDestroy {
@@ -130,6 +133,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
pinLockWithMasterPassword: false,
biometric: false,
enableAutoBiometricsPrompt: true,
enablePhishingDetection: true,
});
protected showAccountSecurityNudge$: Observable<boolean> =
@@ -141,6 +145,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
);
protected readonly consolidatedSessionTimeoutComponent$: Observable<boolean>;
protected readonly phishingDetectionAvailable$: Observable<boolean>;
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
private destroy$ = new Subject<void>();
@@ -167,10 +172,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private vaultNudgesService: NudgesService,
private validationService: ValidationService,
private logService: LogService,
private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction,
) {
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.ConsolidatedSessionTimeoutComponent,
);
// Check if user phishing detection available
this.phishingDetectionAvailable$ = this.phishingDetectionSettingsService.available$;
}
async ngOnInit() {
@@ -251,6 +260,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
enableAutoBiometricsPrompt: await firstValueFrom(
this.biometricStateService.promptAutomatically$,
),
enablePhishingDetection: await firstValueFrom(this.phishingDetectionSettingsService.enabled$),
};
this.form.patchValue(initialValues, { emitEvent: false });
@@ -361,6 +371,16 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
)
.subscribe();
this.form.controls.enablePhishingDetection.valueChanges
.pipe(
concatMap(async (enabled) => {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.phishingDetectionSettingsService.setEnabled(userId, enabled);
}),
takeUntil(this.destroy$),
)
.subscribe();
this.refreshTimeoutSettings$
.pipe(
switchMap(() =>

View File

@@ -4,7 +4,7 @@ import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { ExtensionCommand } from "@bitwarden/common/autofill/constants";
@@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -80,11 +81,12 @@ describe("NotificationBackground", () => {
const organizationService = mock<OrganizationService>();
const userId = "testId" as UserId;
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
const activeAccountSubject = new BehaviorSubject({
id: userId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
beforeEach(() => {

View File

@@ -18,6 +18,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -123,9 +124,10 @@ describe("context-menu", () => {
autofillSettingsService.enableContextMenu$ = of(true);
accountService.activeAccount$ = of({
id: "userId" as UserId,
email: "",
emailVerified: false,
name: undefined,
...mockAccountInfoWith({
email: "",
name: undefined,
}),
});
});

View File

@@ -82,7 +82,9 @@ import {
import { isUrlInList } from "@bitwarden/common/autofill/utils";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import { PhishingDetectionSettingsService } from "@bitwarden/common/dirt/services/phishing-detection/phishing-detection-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import {
@@ -497,6 +499,7 @@ export default class MainBackground {
// DIRT
private phishingDataService: PhishingDataService;
private phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction;
constructor() {
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
@@ -1475,12 +1478,18 @@ export default class MainBackground {
this.platformUtilsService,
);
PhishingDetectionService.initialize(
this.phishingDetectionSettingsService = new PhishingDetectionSettingsService(
this.accountService,
this.billingAccountProfileStateService,
this.configService,
this.organizationService,
this.stateProvider,
);
PhishingDetectionService.initialize(
this.logService,
this.phishingDataService,
this.phishingDetectionSettingsService,
messageListener,
);

View File

@@ -1,9 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener } from "@bitwarden/messaging";
@@ -11,17 +9,12 @@ import { PhishingDataService } from "./phishing-data.service";
import { PhishingDetectionService } from "./phishing-detection.service";
describe("PhishingDetectionService", () => {
let accountService: AccountService;
let billingAccountProfileStateService: BillingAccountProfileStateService;
let configService: ConfigService;
let logService: LogService;
let phishingDataService: MockProxy<PhishingDataService>;
let messageListener: MockProxy<MessageListener>;
let phishingDetectionSettingsService: MockProxy<PhishingDetectionSettingsServiceAbstraction>;
beforeEach(() => {
accountService = { getAccount$: jest.fn(() => of(null)) } as any;
billingAccountProfileStateService = {} as any;
configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = mock();
messageListener = mock<MessageListener>({
@@ -29,16 +22,17 @@ describe("PhishingDetectionService", () => {
return new Observable();
},
});
phishingDetectionSettingsService = mock<PhishingDetectionSettingsServiceAbstraction>({
on$: of(true),
});
});
it("should initialize without errors", () => {
expect(() => {
PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
phishingDetectionSettingsService,
messageListener,
);
}).not.toThrow();
@@ -61,6 +55,7 @@ describe("PhishingDetectionService", () => {
// logService,
// phishingDataService,
// messageListener,
// phishingDetectionSettingsService,
// );
// });
@@ -81,6 +76,7 @@ describe("PhishingDetectionService", () => {
// logService,
// phishingDataService,
// messageListener,
// phishingDetectionSettingsService,
// );
// });
});

View File

@@ -1,21 +1,16 @@
import {
combineLatest,
concatMap,
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
of,
Subject,
switchMap,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
@@ -50,11 +45,9 @@ export class PhishingDetectionService {
private static _didInit = false;
static initialize(
accountService: AccountService,
billingAccountProfileStateService: BillingAccountProfileStateService,
configService: ConfigService,
logService: LogService,
phishingDataService: PhishingDataService,
phishingDetectionSettingsService: PhishingDetectionSettingsServiceAbstraction,
messageListener: MessageListener,
) {
if (this._didInit) {
@@ -118,22 +111,9 @@ export class PhishingDetectionService {
.messages$(PHISHING_DETECTION_CANCEL_COMMAND)
.pipe(switchMap((message) => BrowserApi.closeTab(message.tabId)));
const activeAccountHasAccess$ = combineLatest([
accountService.activeAccount$,
configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account) {
logService.debug("[PhishingDetectionService] No active account.");
return of(false);
}
return billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => hasPremium && featureEnabled));
}),
);
const phishingDetectionActive$ = phishingDetectionSettingsService.on$;
const initSub = activeAccountHasAccess$
const initSub = phishingDetectionActive$
.pipe(
distinctUntilChanged(),
switchMap((activeUserHasAccess) => {

View File

@@ -120,8 +120,8 @@ export class PopupRouterCacheService {
/**
* Navigate back in history
*/
async back() {
if (!BrowserPopupUtils.inPopup(window)) {
async back(updateCache = false) {
if (!updateCache && !BrowserPopupUtils.inPopup(window)) {
this.location.back();
return;
}

View File

@@ -1,31 +1,21 @@
<popup-page [disablePadding]="true">
<popup-header
slot="header"
[background]="'alt'"
[showBackButton]="showBackButton"
[pageTitle]="''"
>
<div class="tw-w-32">
<bit-icon *ngIf="showLogo" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</div>
<ng-container slot="end">
<app-pop-out></app-pop-out>
<app-current-account *ngIf="showAcctSwitcher && hasLoggedInAccount"></app-current-account>
</ng-container>
</popup-header>
<auth-anon-layout
[title]="pageTitle"
[subtitle]="pageSubtitle"
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
[hideLogo]="true"
[hideLogo]="!showLogo"
[maxWidth]="maxWidth"
[hideFooter]="hideFooter"
[hideCardWrapper]="hideCardWrapper"
>
<router-outlet></router-outlet>
<div class="tw-flex tw-gap-2" slot="header-actions">
<app-pop-out></app-pop-out>
@if (showAcctSwitcher && hasLoggedInAccount) {
<app-current-account></app-current-account>
}
</div>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
</auth-anon-layout>

View File

@@ -76,11 +76,14 @@ const decorators = (options: {
{
provide: AccountService,
useValue: {
// We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec.
// This is because that package relies on jest dependencies that aren't available here.
activeAccount$: of({
id: "test-user-id" as UserId,
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
creationDate: "2024-01-01T00:00:00.000Z",
}),
},
},
@@ -238,6 +241,11 @@ export const DefaultContentExample: Story = {
},
],
}),
parameters: {
chromatic: {
viewports: [380, 1280],
},
},
};
// Dynamic Content Example

View File

@@ -41,6 +41,7 @@ import {
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@@ -67,6 +68,8 @@ import {
UserNotificationSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { PhishingDetectionSettingsService } from "@bitwarden/common/dirt/services/phishing-detection/phishing-detection-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -512,6 +515,17 @@ const safeProviders: SafeProvider[] = [
useClass: UserNotificationSettingsService,
deps: [StateProvider],
}),
safeProvider({
provide: PhishingDetectionSettingsServiceAbstraction,
useClass: PhishingDetectionSettingsService,
deps: [
AccountService,
BillingAccountProfileStateService,
ConfigService,
OrganizationService,
StateProvider,
],
}),
safeProvider({
provide: MessageListener,
useFactory: (subject: Subject<Message<Record<string, unknown>>>, ngZone: NgZone) =>

View File

@@ -16,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
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 { mockAccountInfoWith } from "@bitwarden/common/spec";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -96,9 +97,10 @@ describe("SendV2Component", () => {
useValue: {
activeAccount$: of({
id: "123",
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
}),
},
},

View File

@@ -1,5 +1,5 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'exportVault' | i18n" showBackButton>
<popup-header slot="header" [pageTitle]="'export' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
@@ -21,7 +21,7 @@
bitFormButton
buttonType="primary"
>
{{ "exportVault" | i18n }}
{{ "export" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [popupBackAction]>
{{ "cancel" | i18n }}

View File

@@ -1,5 +1,5 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'importData' | i18n" showBackButton>
<popup-header slot="header" [pageTitle]="'import' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
@@ -22,7 +22,7 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</popup-footer>
</popup-page>

View File

@@ -51,6 +51,6 @@ export class AttachmentsV2Component {
/** Navigate the user back to the edit screen after uploading an attachment */
async navigateBack() {
await this.popupRouterCacheService.back();
await this.popupRouterCacheService.back(true);
}
}

View File

@@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
@@ -60,9 +61,10 @@ describe("OpenAttachmentsComponent", () => {
const accountService = {
activeAccount$: of({
id: mockUserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
}),
};
const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled");

View File

@@ -15,7 +15,7 @@
<bit-item>
<button type="button" bit-item-content (click)="import()">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-2">
<p>{{ "importItems" | i18n }}</p>
<p>{{ "import" | i18n }}</p>
<span
*ngIf="emptyVaultImportBadge$ | async"
bitBadge
@@ -30,7 +30,7 @@
</bit-item>
<bit-item>
<a bit-item-content routerLink="/export">
{{ "exportVault" | i18n }}
{{ "export" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@@ -64,7 +64,7 @@
</bit-item>
<bit-item>
<button type="button" bit-item-content (click)="sync()">
{{ "syncVaultNow" | i18n }}
{{ "syncNow" | i18n }}
<span slot="secondary">{{ lastSync }}</span>
<i slot="end" class="bwi bwi-refresh" aria-hidden="true"></i>
</button>

View File

@@ -15,6 +15,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
@@ -48,9 +49,10 @@ describe("UnlockCommand", () => {
const mockMasterPassword = "testExample";
const activeAccount: Account = {
id: "user-id" as UserId,
email: "user@example.com",
emailVerified: true,
name: "User",
...mockAccountInfoWith({
email: "user@example.com",
name: "User",
}),
};
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockSessionKey = new Uint8Array(64) as CsprngArray;

View File

@@ -113,8 +113,8 @@ if (process.platform === "linux") {
platformTargets.forEach(([target, _]) => {
installTarget(target);
buildNapiModule(target);
buildProxyBin(target);
buildImporterBinaries(target);
buildNapiModule(target, mode === "release");
buildProxyBin(target, mode === "release");
buildImporterBinaries(target, mode === "release");
buildProcessIsolation();
});

View File

@@ -18,7 +18,7 @@ use crate::{
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: &[".config/google-chrome"],
data_dir: &[".config/google-chrome", "snap/chromium/common/chromium"],
},
BrowserConfig {
name: "Chromium",

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "",
"scripts": {
"build": "napi build --platform --no-js",
"build": "node scripts/build.js",
"test": "cargo test"
},
"author": "",

View File

@@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { execSync } = require('child_process');
const args = process.argv.slice(2);
const isRelease = args.includes('--release');
const argsString = args.join(' ');
if (isRelease) {
console.log('Building release mode.');
execSync(`napi build --platform --no-js ${argsString}`, { stdio: 'inherit'});
} else {
console.log('Building debug mode.');
execSync(`napi build --platform --no-js ${argsString}`, {
stdio: 'inherit',
env: { ...process.env, RUST_LOG: 'debug' }
});
}

View File

@@ -994,7 +994,7 @@ pub mod logging {
};
use tracing::Level;
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
filter::EnvFilter,
fmt::format::{DefaultVisitor, Writer},
layer::SubscriberExt,
util::SubscriberInitExt,
@@ -1082,9 +1082,17 @@ pub mod logging {
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<FnArgs<(LogLevel, String)>>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
// - if RUST_LOG is detected at runtime
// - if RUST_LOG is provided at compile time
// - default to INFO
let filter = EnvFilter::builder()
// set the default log level to INFO.
.with_default_directive(LevelFilter::INFO.into())
.with_default_directive(
option_env!("RUST_LOG")
.unwrap_or("info")
.parse()
.expect("should provide valid log level at compile time."),
)
// parse directives from the RUST_LOG environment variable,
// overriding the default directive for matching targets.
.from_env_lossy();

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.12.0",
"version": "2025.12.1",
"keywords": [
"bitwarden",
"password",

View File

@@ -203,8 +203,16 @@ const safeProviders: SafeProvider[] = [
// We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid
// the TokenService having to inject the PlatformUtilsService which introduces a
// circular dependency on Desktop only.
//
// For Windows portable builds, we disable secure storage to ensure tokens are
// stored on disk (in bitwarden-appdata) rather than in Windows Credential
// Manager, making them portable across machines. This allows users to move the USB drive
// between computers while maintaining authentication.
//
// Note: Portable mode does not use secure storage for read/write/clear operations,
// preventing any collision with tokens from a regular desktop installation.
provide: SUPPORTS_SECURE_STORAGE,
useValue: ELECTRON_SUPPORTS_SECURE_STORAGE,
useValue: ELECTRON_SUPPORTS_SECURE_STORAGE && !ipc.platform.isWindowsPortable,
}),
safeProvider({
provide: DEFAULT_VAULT_TIMEOUT,

View File

@@ -1,5 +1,5 @@
<bit-dialog #dialog dialogSize="large">
<span bitDialogTitle>{{ "exportVault" | i18n }}</span>
<span bitDialogTitle>{{ "export" | i18n }}</span>
<ng-container bitDialogContent>
<tools-export
(formLoading)="this.loading = $event"
@@ -17,7 +17,7 @@
bitFormButton
buttonType="primary"
>
{{ "exportVault" | i18n }}
{{ "export" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}

View File

@@ -1,5 +1,5 @@
<bit-dialog #dialog dialogSize="large" background="alt">
<span bitDialogTitle>{{ "importData" | i18n }}</span>
<span bitDialogTitle>{{ "import" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-relative">
<tools-import
@@ -27,7 +27,7 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}

View File

@@ -7,6 +7,7 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
@@ -40,9 +41,10 @@ describe("Fido2CreateComponent", () => {
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: "test-user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
beforeEach(async () => {

View File

@@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Account, UserId } from "@bitwarden/common/platform/models/domain/account";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service";
@@ -30,9 +31,10 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => {
beforeEach(() => {
mockAccountSubject = new BehaviorSubject<Account | null>({
id: mockUserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
mockFeatureFlagSubject = new BehaviorSubject<boolean>(true);
mockAuthStatusSubject = new BehaviorSubject<AuthenticationStatus>(

View File

@@ -13,11 +13,13 @@ import { UserKey } from "@bitwarden/common/types/key";
import { BiometricStateService, KdfConfigService } from "@bitwarden/key-management";
import {
makeSymmetricCryptoKey,
FakeAccountService,
mockAccountServiceWith,
FakeStateProvider,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../../libs/common/spec";
// eslint-disable-next-line no-restricted-imports
import { VAULT_TIMEOUT } from "../../../../libs/common/src/key-management/vault-timeout";
import { DesktopBiometricsService } from "./biometrics/desktop.biometrics.service";
import { ElectronKeyService } from "./electron-key.service";
@@ -40,11 +42,13 @@ describe("ElectronKeyService", () => {
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
beforeEach(() => {
beforeEach(async () => {
accountService = mockAccountServiceWith(mockUserId);
masterPasswordService = new FakeMasterPasswordService();
stateProvider = new FakeStateProvider(accountService);
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
keyService = new ElectronKeyService(
masterPasswordService,
keyGenerationService,
@@ -79,38 +83,17 @@ describe("ElectronKeyService", () => {
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId);
});
describe("biometric unlock enabled", () => {
beforeEach(() => {
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
});
it("sets biometric key when biometric unlock enabled", async () => {
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
it("sets null biometric client key half and biometric unlock key when require password on start disabled", async () => {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(false);
await keyService.setUserKey(userKey, mockUserId);
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey,
);
expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled();
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId);
});
describe("require password on start enabled", () => {
beforeEach(() => {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
});
it("sets biometric key", async () => {
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey,
);
});
});
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey,
);
expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled();
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId);
});
});
});

View File

@@ -1198,8 +1198,8 @@
"followUs": {
"message": "Follow us"
},
"syncVault": {
"message": "Sync vault"
"syncNow": {
"message": "Sync now"
},
"changeMasterPass": {
"message": "Change master password"
@@ -1775,8 +1775,11 @@
"exportFrom": {
"message": "Export from"
},
"exportVault": {
"message": "Export vault"
"export": {
"message": "Export"
},
"import": {
"message": "Import"
},
"fileFormat": {
"message": "File format"
@@ -3492,10 +3495,6 @@
"aliasDomain": {
"message": "Alias domain"
},
"importData": {
"message": "Import data",
"description": "Used for the desktop menu item and the header of the import dialog"
},
"importError": {
"message": "Import error"
},

View File

@@ -146,8 +146,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
private get syncVault(): MenuItemConstructorOptions {
return {
id: "syncVault",
label: this.localize("syncVault"),
id: "syncNow",
label: this.localize("syncNow"),
click: () => this.sendMessage("syncVault"),
enabled: this.hasAuthenticatedAccounts,
};
@@ -155,8 +155,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
private get importVault(): MenuItemConstructorOptions {
return {
id: "importVault",
label: this.localize("importData"),
id: "import",
label: this.localize("import"),
click: () => this.sendMessage("importVault"),
enabled: !this._isLocked,
};
@@ -164,8 +164,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
private get exportVault(): MenuItemConstructorOptions {
return {
id: "exportVault",
label: this.localize("exportVault"),
id: "export",
label: this.localize("export"),
click: () => this.sendMessage("exportVault"),
enabled: !this._isLocked,
};

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.12.0",
"version": "2025.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.12.0",
"version": "2025.12.1",
"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": "2025.12.0",
"version": "2025.12.1",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -17,6 +17,7 @@ import {
isFlatpak,
isMacAppStore,
isSnapStore,
isWindowsPortable,
isWindowsStore,
} from "../utils";
@@ -133,6 +134,7 @@ export default {
isDev: isDev(),
isMacAppStore: isMacAppStore(),
isWindowsStore: isWindowsStore(),
isWindowsPortable: isWindowsPortable(),
isFlatpak: isFlatpak(),
isSnapStore: isSnapStore(),
isAppImage: isAppImage(),

View File

@@ -2,7 +2,7 @@ import { NgZone } from "@angular/core";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, of, take, timeout, timer } from "rxjs";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -10,7 +10,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService } from "@bitwarden/common/spec";
import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
@@ -23,17 +23,15 @@ import { BiometricMessageHandlerService } from "./biometric-message-handler.serv
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: {
const accounts = {
[SomeUser]: mockAccountInfoWith({
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
}),
[AnotherUser]: mockAccountInfoWith({
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
}),
};
describe("BiometricMessageHandlerService", () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.12.0",
"version": "2025.12.1",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -104,12 +104,12 @@
*ngIf="organization.use2fa && organization.isOwner"
></bit-nav-item>
<bit-nav-item
[text]="'importData' | i18n"
[text]="'import' | i18n"
route="settings/tools/import"
*ngIf="organization.canAccessImport"
></bit-nav-item>
<bit-nav-item
[text]="'exportVault' | i18n"
[text]="'export' | i18n"
route="settings/tools/export"
*ngIf="canAccessExport$ | async"
></bit-nav-item>

View File

@@ -57,7 +57,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)],
data: {
titleId: "importData",
titleId: "import",
},
},
{
@@ -68,7 +68,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)],
data: {
titleId: "exportVault",
titleId: "export",
},
},
],

View File

@@ -1,21 +1,37 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { DeleteRecoverRequest } from "@bitwarden/common/models/request/delete-recover.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-delete",
templateUrl: "recover-delete.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
TypographyModule,
],
})
export class RecoverDeleteComponent {
protected recoverDeleteForm = new FormGroup({
@@ -29,7 +45,6 @@ export class RecoverDeleteComponent {
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private toastService: ToastService,
) {}

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { Router, provideRouter } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import {
@@ -7,69 +7,49 @@ import {
LoginSuccessHandlerService,
PasswordLoginCredentials,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { I18nPipe } from "@bitwarden/ui-common";
import { RecoverTwoFactorComponent } from "./recover-two-factor.component";
describe("RecoverTwoFactorComponent", () => {
let component: RecoverTwoFactorComponent;
let fixture: ComponentFixture<RecoverTwoFactorComponent>;
// Mock Services
let mockRouter: MockProxy<Router>;
let mockApiService: MockProxy<ApiService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockI18nService: MockProxy<I18nService>;
let mockKeyService: MockProxy<KeyService>;
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let mockToastService: MockProxy<ToastService>;
let mockConfigService: MockProxy<ConfigService>;
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
let mockLogService: MockProxy<LogService>;
let mockValidationService: MockProxy<ValidationService>;
beforeEach(() => {
mockRouter = mock<Router>();
mockApiService = mock<ApiService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockKeyService = mock<KeyService>();
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockToastService = mock<ToastService>();
mockConfigService = mock<ConfigService>();
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
mockLogService = mock<LogService>();
mockValidationService = mock<ValidationService>();
TestBed.configureTestingModule({
declarations: [RecoverTwoFactorComponent],
await TestBed.configureTestingModule({
imports: [RecoverTwoFactorComponent],
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: ApiService, useValue: mockApiService },
{ provide: PlatformUtilsService, mockPlatformUtilsService },
provideRouter([]),
{ provide: I18nService, useValue: mockI18nService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
{ provide: ToastService, useValue: mockToastService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
{ provide: LogService, useValue: mockLogService },
{ provide: ValidationService, useValue: mockValidationService },
],
imports: [I18nPipe],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownElements: false,
});
}).compileComponents();
mockRouter = TestBed.inject(Router) as MockProxy<Router>;
jest.spyOn(mockRouter, "navigate");
fixture = TestBed.createComponent(RecoverTwoFactorComponent);
component = fixture.componentInstance;

View File

@@ -1,8 +1,9 @@
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginStrategyServiceAbstraction,
PasswordLoginCredentials,
@@ -14,14 +15,32 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
LinkModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-two-factor",
templateUrl: "recover-two-factor.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
LinkModule,
TypographyModule,
],
})
export class RecoverTwoFactorComponent implements OnInit {
formGroup = new FormGroup({

View File

@@ -10,7 +10,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { VerifyEmailRequest } from "@bitwarden/common/models/request/verify-email.request";
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 { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -18,12 +17,10 @@ import { ToastService } from "@bitwarden/components";
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",
standalone: false,
})
export class VerifyEmailTokenComponent implements OnInit {
constructor(
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private apiService: ApiService,

View File

@@ -1,22 +1,36 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { FormGroup, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { first } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VerifyDeleteRecoverRequest } from "@bitwarden/common/models/request/verify-delete-recover.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
CalloutComponent,
ToastService,
TypographyModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete",
templateUrl: "verify-recover-delete.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
CalloutComponent,
TypographyModule,
],
})
export class VerifyRecoverDeleteComponent implements OnInit {
email: string;
@@ -28,7 +42,6 @@ export class VerifyRecoverDeleteComponent implements OnInit {
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private toastService: ToastService,

View File

@@ -10,6 +10,7 @@ import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
@@ -63,9 +64,10 @@ describe("UnifiedUpgradeDialogComponent", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const defaultDialogData: UnifiedUpgradeDialogParams = {

View File

@@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogRef, DialogService } from "@bitwarden/components";
@@ -32,9 +33,10 @@ describe("UpgradeNavButtonComponent", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
beforeEach(async () => {

View File

@@ -13,6 +13,7 @@ import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
@@ -46,11 +47,12 @@ describe("UpgradePaymentService", () => {
let sut: UpgradePaymentService;
const mockAccount = {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const mockTokenizedPaymentMethod: TokenizedPaymentMethod = {
@@ -151,9 +153,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -203,9 +206,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {
@@ -255,9 +259,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
mockAccountService.activeAccount$ = of(mockAccount);
@@ -289,9 +294,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const expectedCredit = 25.5;
@@ -353,9 +359,10 @@ describe("UpgradePaymentService", () => {
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
name: "Test User",
emailVerified: true,
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
};
const paidOrgData = {

View File

@@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { BreachReportComponent } from "./breach-report.component";
@@ -38,9 +39,10 @@ describe("BreachReportComponent", () => {
let accountService: MockProxy<AccountService>;
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
beforeEach(async () => {

View File

@@ -30,6 +30,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
@@ -286,9 +287,10 @@ describe("KeyRotationService", () => {
const mockUser = {
id: "mockUserId" as UserId,
email: "mockEmail",
emailVerified: true,
name: "mockName",
...mockAccountInfoWith({
email: "mockEmail",
name: "mockName",
}),
};
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];

View File

@@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> {
}
class MockAccountService implements Partial<AccountService> {
// We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec.
// This is because that package relies on jest dependencies that aren't available here.
activeAccount$?: Observable<Account> = of({
id: "test-user-id" as UserId,
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
creationDate: "2024-01-01T00:00:00.000Z",
});
}

View File

@@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> {
}
class MockAccountService implements Partial<AccountService> {
// We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec.
// This is because that package relies on jest dependencies that aren't available here.
activeAccount$?: Observable<Account> = of({
id: "test-user-id" as UserId,
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
creationDate: "2024-01-01T00:00:00.000Z",
});
}

View File

@@ -6,8 +6,8 @@
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
<bit-nav-group icon="bwi-wrench" [text]="'tools' | i18n" route="tools">
<bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item>
<bit-nav-item [text]="'importData' | i18n" route="tools/import"></bit-nav-item>
<bit-nav-item [text]="'exportVault' | i18n" route="tools/export"></bit-nav-item>
<bit-nav-item [text]="'import' | i18n" route="tools/import"></bit-nav-item>
<bit-nav-item [text]="'export' | i18n" route="tools/export"></bit-nav-item>
</bit-nav-group>
<bit-nav-item icon="bwi-sliders" [text]="'reports' | i18n" route="reports"></bit-nav-item>
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">

View File

@@ -748,7 +748,7 @@ const routes: Routes = [
loadComponent: () =>
import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent),
data: {
titleId: "importData",
titleId: "import",
} satisfies RouteDataProperties,
},
{
@@ -758,7 +758,7 @@ const routes: Routes = [
(mod) => mod.ExportWebComponent,
),
data: {
titleId: "exportVault",
titleId: "export",
} satisfies RouteDataProperties,
},
{

View File

@@ -1,9 +1,5 @@
import { NgModule } from "@angular/core";
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component";
import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component";
@@ -18,20 +14,10 @@ import { SharedModule } from "./shared.module";
@NgModule({
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
declarations: [
RecoverDeleteComponent,
RecoverTwoFactorComponent,
SponsoredFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
],
exports: [
RecoverDeleteComponent,
RecoverTwoFactorComponent,
SponsoredFamiliesComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
],
exports: [SponsoredFamiliesComponent],
})
export class LooseComponentsModule {}

View File

@@ -15,6 +15,6 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</bit-container>

View File

@@ -16,6 +16,6 @@
bitFormButton
buttonType="primary"
>
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</bit-container>

View File

@@ -15,6 +15,6 @@
bitFormButton
buttonType="primary"
>
{{ "confirmFormat" | i18n }}
{{ "export" | i18n }}
</button>
</bit-container>

View File

@@ -16,6 +16,6 @@
bitFormButton
buttonType="primary"
>
{{ "confirmFormat" | i18n }}
{{ "export" | i18n }}
</button>
</bit-container>

View File

@@ -2,14 +2,18 @@ import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DeviceType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
FakeStateProvider,
mockAccountServiceWith,
mockAccountInfoWith,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@@ -27,8 +31,11 @@ describe("VaultBannersService", () => {
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
const getEmailVerified = jest.fn().mockResolvedValue(true);
const lastSync$ = new BehaviorSubject<Date | null>(null);
const accounts$ = new BehaviorSubject<Record<UserId, AccountInfo>>({
[userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo,
const accounts$ = new BehaviorSubject({
[userId]: mockAccountInfoWith({
email: "test@bitwarden.com",
name: "name",
}),
});
const pendingAuthRequests$ = new BehaviorSubject<Array<AuthRequestResponse>>([]);

View File

@@ -1975,12 +1975,6 @@
"exportFrom": {
"message": "Export from"
},
"exportVault": {
"message": "Export vault"
},
"exportSecrets": {
"message": "Export secrets"
},
"fileFormat": {
"message": "File format"
},
@@ -1993,9 +1987,6 @@
"confirmMasterPassword": {
"message": "Confirm master password"
},
"confirmFormat": {
"message": "Confirm format"
},
"filePassword": {
"message": "File password"
},
@@ -2306,6 +2297,9 @@
"tools": {
"message": "Tools"
},
"import": {
"message": "Import"
},
"importData": {
"message": "Import data"
},
@@ -8757,9 +8751,6 @@
"server": {
"message": "Server"
},
"exportData": {
"message": "Export data"
},
"exportingOrganizationSecretDataTitle": {
"message": "Exporting Organization Secret Data"
},

View File

@@ -1,14 +1,15 @@
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
import { OrgIntegrationConfiguration } from "../integration-builder";
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
export class DatadogConfiguration {
export class DatadogConfiguration implements OrgIntegrationConfiguration {
uri: string;
apiKey: string;
service: OrganizationIntegrationServiceType;
service: OrganizationIntegrationServiceName;
constructor(uri: string, apiKey: string, service: string) {
constructor(uri: string, apiKey: string, service: OrganizationIntegrationServiceName) {
this.uri = uri;
this.apiKey = apiKey;
this.service = service as OrganizationIntegrationServiceType;
this.service = service;
}
toString(): string {

View File

@@ -1,15 +1,16 @@
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
import { OrgIntegrationConfiguration } from "../integration-builder";
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
export class HecConfiguration {
export class HecConfiguration implements OrgIntegrationConfiguration {
uri: string;
scheme = "Bearer";
token: string;
service: OrganizationIntegrationServiceType;
service: OrganizationIntegrationServiceName;
constructor(uri: string, token: string, service: string) {
constructor(uri: string, token: string, service: OrganizationIntegrationServiceName) {
this.uri = uri;
this.token = token;
this.service = service as OrganizationIntegrationServiceType;
this.service = service;
}
toString(): string {

View File

@@ -1,11 +1,16 @@
import { OrgIntegrationConfiguration } from "../integration-builder";
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookConfiguration {
export class WebhookConfiguration implements OrgIntegrationConfiguration {
propA: string;
propB: string;
service: OrganizationIntegrationServiceName;
constructor(propA: string, propB: string) {
constructor(propA: string, propB: string, service: OrganizationIntegrationServiceName) {
this.propA = propA;
this.propB = propB;
this.service = service;
}
toString(): string {

View File

@@ -0,0 +1,94 @@
import { DatadogConfiguration } from "./configuration/datadog-configuration";
import { HecConfiguration } from "./configuration/hec-configuration";
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
import { OrganizationIntegrationType } from "./organization-integration-type";
/**
* Defines the structure for organization integration configuration
*/
export interface OrgIntegrationConfiguration {
service: OrganizationIntegrationServiceName;
toString(): string;
}
/**
* Defines the structure for organization integration template
*/
export interface OrgIntegrationTemplate {
service: OrganizationIntegrationServiceName;
toString(): string;
}
/**
* Builder class for creating organization integration configurations and templates
*/
export class OrgIntegrationBuilder {
static buildHecConfiguration(
uri: string,
token: string,
service: OrganizationIntegrationServiceName,
): OrgIntegrationConfiguration {
return new HecConfiguration(uri, token, service);
}
static buildHecTemplate(
index: string,
service: OrganizationIntegrationServiceName,
): OrgIntegrationTemplate {
return new HecTemplate(index, service);
}
static buildDataDogConfiguration(uri: string, apiKey: string): OrgIntegrationConfiguration {
return new DatadogConfiguration(uri, apiKey, OrganizationIntegrationServiceName.Datadog);
}
static buildDataDogTemplate(service: OrganizationIntegrationServiceName): OrgIntegrationTemplate {
return new DatadogTemplate(service);
}
static buildConfiguration(
type: OrganizationIntegrationType,
configuration: string,
): OrgIntegrationConfiguration {
switch (type) {
case OrganizationIntegrationType.Hec: {
const hecConfig = this.convertToJson<HecConfiguration>(configuration);
return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.service);
}
case OrganizationIntegrationType.Datadog: {
const datadogConfig = this.convertToJson<DatadogConfiguration>(configuration);
return this.buildDataDogConfiguration(datadogConfig.uri, datadogConfig.apiKey);
}
default:
throw new Error(`Unsupported integration type: ${type}`);
}
}
static buildTemplate(
type: OrganizationIntegrationType,
template: string,
): OrgIntegrationTemplate {
switch (type) {
case OrganizationIntegrationType.Hec: {
const hecTemplate = this.convertToJson<HecTemplate>(template);
return this.buildHecTemplate(hecTemplate.index, hecTemplate.service);
}
case OrganizationIntegrationType.Datadog: {
const datadogTemplate = this.convertToJson<DatadogTemplate>(template);
return this.buildDataDogTemplate(datadogTemplate.service);
}
default:
throw new Error(`Unsupported integration type: ${type}`);
}
}
private static convertToJson<T>(jsonString?: string): T {
try {
return JSON.parse(jsonString || "{}") as T;
} catch {
throw new Error("Invalid integration configuration: JSON parse error");
}
}
}

View File

@@ -1,14 +1,15 @@
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
import { OrgIntegrationTemplate } from "../../integration-builder";
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
export class DatadogTemplate {
export class DatadogTemplate implements OrgIntegrationTemplate {
source_type_name = "Bitwarden";
title: string = "#Title#";
text: string =
"ActingUser: #ActingUserId#\nUser: #UserId#\nEvent: #Type#\nOrganization: #OrganizationId#\nPolicyId: #PolicyId#\nIpAddress: #IpAddress#\nDomainName: #DomainName#\nCipherId: #CipherId#\n";
service: OrganizationIntegrationServiceType;
service: OrganizationIntegrationServiceName;
constructor(service: string) {
this.service = service as OrganizationIntegrationServiceType;
constructor(service: OrganizationIntegrationServiceName) {
this.service = service;
}
toString(): string {

View File

@@ -1,14 +1,15 @@
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
import { OrgIntegrationTemplate } from "../../integration-builder";
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
export class HecTemplate {
export class HecTemplate implements OrgIntegrationTemplate {
event = "#EventMessage#";
source = "Bitwarden";
index: string;
service: OrganizationIntegrationServiceType;
service: OrganizationIntegrationServiceName;
constructor(index: string, service: string) {
constructor(index: string, service: OrganizationIntegrationServiceName) {
this.index = index;
this.service = service as OrganizationIntegrationServiceType;
this.service = service;
}
toString(): string {

View File

@@ -1,9 +1,14 @@
import { OrgIntegrationTemplate } from "../../integration-builder";
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookTemplate {
export class WebhookTemplate implements OrgIntegrationTemplate {
service: OrganizationIntegrationServiceName;
propA: string;
propB: string;
constructor(propA: string, propB: string) {
constructor(service: OrganizationIntegrationServiceName, propA: string, propB: string) {
this.service = service;
this.propA = propA;
this.propB = propB;
}

View File

@@ -4,31 +4,25 @@ import {
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template";
import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config";
import { OrgIntegrationTemplate } from "./integration-builder";
export class OrganizationIntegrationConfiguration {
id: OrganizationIntegrationConfigurationId;
integrationId: OrganizationIntegrationId;
eventType?: EventType | null;
configuration?: WebhookIntegrationConfigurationConfig | null;
filters?: string;
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null;
template?: OrgIntegrationTemplate | null;
constructor(
id: OrganizationIntegrationConfigurationId,
integrationId: OrganizationIntegrationId,
eventType?: EventType | null,
configuration?: WebhookIntegrationConfigurationConfig | null,
filters?: string,
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null,
template?: OrgIntegrationTemplate | null,
) {
this.id = id;
this.integrationId = integrationId;
this.eventType = eventType;
this.configuration = configuration;
this.filters = filters;
this.template = template;
}

View File

@@ -1,7 +1,7 @@
export const OrganizationIntegrationServiceType = Object.freeze({
export const OrganizationIntegrationServiceName = Object.freeze({
CrowdStrike: "CrowdStrike",
Datadog: "Datadog",
} as const);
export type OrganizationIntegrationServiceType =
(typeof OrganizationIntegrationServiceType)[keyof typeof OrganizationIntegrationServiceType];
export type OrganizationIntegrationServiceName =
(typeof OrganizationIntegrationServiceName)[keyof typeof OrganizationIntegrationServiceName];

View File

@@ -1,29 +1,27 @@
import { OrganizationIntegrationId } from "@bitwarden/common/types/guid";
import { DatadogConfiguration } from "./configuration/datadog-configuration";
import { HecConfiguration } from "./configuration/hec-configuration";
import { WebhookConfiguration } from "./configuration/webhook-configuration";
import { OrgIntegrationConfiguration } from "./integration-builder";
import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration";
import { OrganizationIntegrationServiceType } from "./organization-integration-service-type";
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
import { OrganizationIntegrationType } from "./organization-integration-type";
export class OrganizationIntegration {
id: OrganizationIntegrationId;
type: OrganizationIntegrationType;
serviceType: OrganizationIntegrationServiceType;
configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null;
serviceName: OrganizationIntegrationServiceName;
configuration: OrgIntegrationConfiguration | null;
integrationConfiguration: OrganizationIntegrationConfiguration[] = [];
constructor(
id: OrganizationIntegrationId,
type: OrganizationIntegrationType,
serviceType: OrganizationIntegrationServiceType,
configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null,
serviceName: OrganizationIntegrationServiceName,
configuration: OrgIntegrationConfiguration | null,
integrationConfiguration: OrganizationIntegrationConfiguration[] = [],
) {
this.id = id;
this.type = type;
this.serviceType = serviceType;
this.serviceName = serviceName;
this.configuration = configuration;
this.integrationConfiguration = integrationConfiguration;
}

View File

@@ -1,184 +0,0 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import {
OrganizationId,
OrganizationIntegrationConfigurationId,
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
import { DatadogConfiguration } from "../models/configuration/datadog-configuration";
import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { DatadogOrganizationIntegrationService } from "./datadog-organization-integration-service";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
describe("DatadogOrganizationIntegrationService", () => {
let service: DatadogOrganizationIntegrationService;
const mockIntegrationApiService = mock<OrganizationIntegrationApiService>();
const mockIntegrationConfigurationApiService =
mock<OrganizationIntegrationConfigurationApiService>();
const organizationId = "org-1" as OrganizationId;
const integrationId = "int-1" as OrganizationIntegrationId;
const configId = "conf-1" as OrganizationIntegrationConfigurationId;
const serviceType = OrganizationIntegrationServiceType.CrowdStrike;
const url = "https://example.com";
const apiKey = "token";
beforeEach(() => {
service = new DatadogOrganizationIntegrationService(
mockIntegrationApiService,
mockIntegrationConfigurationApiService,
);
jest.resetAllMocks();
});
it("should set organization integrations", (done) => {
mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
service.setOrganizationIntegrations(organizationId);
const subscription = service.integrations$.subscribe((integrations) => {
expect(integrations).toEqual([]);
subscription.unsubscribe();
done();
});
});
it("should save a new Datadog integration", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Datadog,
configuration: JSON.stringify({ url, apiKey, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse);
mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
await service.saveDatadog(organizationId, serviceType, url, apiKey);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations.length).toBe(1);
expect(integrations[0].id).toBe(integrationId);
expect(integrations[0].serviceType).toBe(serviceType);
});
it("should throw error on organization ID mismatch in saveDatadog", async () => {
service.setOrganizationIntegrations("other-org" as OrganizationId);
await expect(service.saveDatadog(organizationId, serviceType, url, apiKey)).rejects.toThrow(
Error("Organization ID mismatch"),
);
});
it("should update an existing Datadog integration", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Datadog,
configuration: JSON.stringify({ url, apiKey, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse);
mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
await service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations.length).toBe(1);
expect(integrations[0].id).toBe(integrationId);
});
it("should throw error on organization ID mismatch in updateDatadog", async () => {
service.setOrganizationIntegrations("other-org" as OrganizationId);
await expect(
service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey),
).rejects.toThrow(Error("Organization ID mismatch"));
});
it("should get integration by id", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[],
),
]);
const integration = await service.getIntegrationById(integrationId);
expect(integration).not.toBeNull();
expect(integration!.id).toBe(integrationId);
});
it("should get integration by service type", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[],
),
]);
const integration = await service.getIntegrationByServiceType(serviceType);
expect(integration).not.toBeNull();
expect(integration!.serviceType).toBe(serviceType);
});
it("should get integration configurations", async () => {
const config = new OrganizationIntegrationConfiguration(
configId,
integrationId,
null,
null,
"",
{} as DatadogTemplate,
);
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[config],
),
]);
const configs = await service.getIntegrationConfigurations(integrationId);
expect(configs).not.toBeNull();
expect(configs![0].id).toBe(configId);
});
it("convertToJson should parse valid JSON", () => {
const obj = service.convertToJson<{ a: number }>('{"a":1}');
expect(obj).toEqual({ a: 1 });
});
it("convertToJson should return null for invalid JSON", () => {
const obj = service.convertToJson<{ a: number }>("invalid");
expect(obj).toBeNull();
});
});

View File

@@ -1,350 +0,0 @@
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import {
OrganizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
} from "@bitwarden/common/types/guid";
import { DatadogConfiguration } from "../models/configuration/datadog-configuration";
import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
export type DatadogModificationFailureReason = {
mustBeOwner: boolean;
success: boolean;
};
export class DatadogOrganizationIntegrationService {
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
private destroy$ = new Subject<void>();
integrations$ = this._integrations$.asObservable();
private fetch$ = this.organizationId$
.pipe(
switchMap(async (orgId) => {
if (orgId) {
const data$ = await this.setIntegrations(orgId);
return await firstValueFrom(data$);
} else {
return this._integrations$.getValue();
}
}),
takeUntil(this.destroy$),
)
.subscribe({
next: (integrations) => {
this._integrations$.next(integrations);
},
});
constructor(
private integrationApiService: OrganizationIntegrationApiService,
private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
) {}
/**
* Sets the organization Id and will trigger the retrieval of the
* integrations for a given org.
* @param orgId
*/
setOrganizationIntegrations(orgId: OrganizationId) {
this.organizationId$.next(orgId);
}
/**
* Saves a new organization integration and updates the integrations$ observable
* @param organizationId id of the organization
* @param service service type of the integration
* @param url url of the service
* @param apiKey api token
*/
async saveDatadog(
organizationId: OrganizationId,
service: OrganizationIntegrationServiceType,
url: string,
apiKey: string,
): Promise<DatadogModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const datadogConfig = new DatadogConfiguration(url, apiKey, service);
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
organizationId,
new OrganizationIntegrationRequest(
OrganizationIntegrationType.Datadog,
datadogConfig.toString(),
),
);
const newTemplate = new DatadogTemplate(service);
const newIntegrationConfigResponse =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId,
newIntegrationResponse.id,
new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()),
);
const newIntegration = this.mapResponsesToOrganizationIntegration(
newIntegrationResponse,
newIntegrationConfigResponse,
);
if (newIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Updates an existing organization integration and updates the integrations$ observable
* @param organizationId id of the organization
* @param OrganizationIntegrationId id of the organization integration
* @param OrganizationIntegrationConfigurationId id of the organization integration configuration
* @param service service type of the integration
* @param url url of the service
* @param apiKey api token
*/
async updateDatadog(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
service: OrganizationIntegrationServiceType,
url: string,
apiKey: string,
): Promise<DatadogModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const datadogConfig = new DatadogConfiguration(url, apiKey, service);
const updatedIntegrationResponse =
await this.integrationApiService.updateOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
new OrganizationIntegrationRequest(
OrganizationIntegrationType.Datadog,
datadogConfig.toString(),
),
);
const updatedTemplate = new DatadogTemplate(service);
const updatedIntegrationConfigResponse =
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
new OrganizationIntegrationConfigurationRequest(
null,
null,
null,
updatedTemplate.toString(),
),
);
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
updatedIntegrationResponse,
updatedIntegrationConfigResponse,
);
if (updatedIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
async deleteDatadog(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
): Promise<DatadogModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// delete the integration
await this.integrationApiService.deleteOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
);
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next(updatedIntegrations);
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Gets a OrganizationIntegration for an OrganizationIntegrationId
* @param integrationId id of the integration
* @returns OrganizationIntegration or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationById(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.id === integrationId) || null),
),
);
}
/**
* Gets a OrganizationIntegration for a service type
* @param serviceType type of the service
* @returns OrganizationIntegration or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationByServiceType(
serviceType: OrganizationIntegrationServiceType,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null),
),
);
}
/**
* Gets a OrganizationIntegrationConfigurations for an integration ID
* @param integrationId id of the integration
* @returns OrganizationIntegration array or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationConfigurations(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegrationConfiguration[] | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => {
const integration = integrations.find((i) => i.id === integrationId);
return integration ? integration.integrationConfiguration : null;
}),
),
);
}
// TODO: Move to data models to be more explicit for future services
private mapResponsesToOrganizationIntegration(
integrationResponse: OrganizationIntegrationResponse,
configurationResponse: OrganizationIntegrationConfigurationResponse,
): OrganizationIntegration | null {
const datadogConfig = this.convertToJson<DatadogConfiguration>(
integrationResponse.configuration,
);
const template = this.convertToJson<DatadogTemplate>(configurationResponse.template);
if (!datadogConfig || !template) {
return null;
}
const integrationConfig = new OrganizationIntegrationConfiguration(
configurationResponse.id,
integrationResponse.id,
null,
null,
"",
template,
);
return new OrganizationIntegration(
integrationResponse.id,
integrationResponse.type,
datadogConfig.service,
datadogConfig,
[integrationConfig],
);
}
// Could possibly be moved to a base service. All services would then assume that the
// integration configuration would always be an array and this datadog specific service
// would just assume a single entry.
private setIntegrations(orgId: OrganizationId) {
const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe(
switchMap(([responses]) => {
const integrations: OrganizationIntegration[] = [];
const promises: Promise<void>[] = [];
responses.forEach((integration) => {
if (integration.type === OrganizationIntegrationType.Datadog) {
const promise = this.integrationConfigurationApiService
.getOrganizationIntegrationConfigurations(orgId, integration.id)
.then((response) => {
// datadog events will only have one OrganizationIntegrationConfiguration
const config = response[0];
const orgIntegration = this.mapResponsesToOrganizationIntegration(
integration,
config,
);
if (orgIntegration !== null) {
integrations.push(orgIntegration);
}
});
promises.push(promise);
}
});
return Promise.all(promises).then(() => {
return integrations;
});
}),
);
return results$;
}
// TODO: Move to base service when necessary
convertToJson<T>(jsonString?: string): T | null {
try {
return JSON.parse(jsonString || "") as T;
} catch {
return null;
}
}
}

View File

@@ -1,201 +0,0 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import {
OrganizationId,
OrganizationIntegrationConfigurationId,
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
import { HecConfiguration } from "../models/configuration/hec-configuration";
import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { HecOrganizationIntegrationService } from "./hec-organization-integration-service";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
describe("HecOrganizationIntegrationService", () => {
let service: HecOrganizationIntegrationService;
const mockIntegrationApiService = mock<OrganizationIntegrationApiService>();
const mockIntegrationConfigurationApiService =
mock<OrganizationIntegrationConfigurationApiService>();
const organizationId = "org-1" as OrganizationId;
const integrationId = "int-1" as OrganizationIntegrationId;
const configId = "conf-1" as OrganizationIntegrationConfigurationId;
const serviceType = OrganizationIntegrationServiceType.CrowdStrike;
const url = "https://example.com";
const bearerToken = "token";
const index = "main";
beforeEach(() => {
service = new HecOrganizationIntegrationService(
mockIntegrationApiService,
mockIntegrationConfigurationApiService,
);
jest.resetAllMocks();
});
it("should set organization integrations", (done) => {
mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
service.setOrganizationIntegrations(organizationId);
const subscription = service.integrations$.subscribe((integrations) => {
expect(integrations).toEqual([]);
subscription.unsubscribe();
done();
});
});
it("should save a new Hec integration", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Hec,
configuration: JSON.stringify({ url, bearerToken, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ index, service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse);
mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
await service.saveHec(organizationId, serviceType, url, bearerToken, index);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations.length).toBe(1);
expect(integrations[0].id).toBe(integrationId);
expect(integrations[0].serviceType).toBe(serviceType);
});
it("should throw error on organization ID mismatch in saveHec", async () => {
service.setOrganizationIntegrations("other-org" as OrganizationId);
await expect(
service.saveHec(organizationId, serviceType, url, bearerToken, index),
).rejects.toThrow(Error("Organization ID mismatch"));
});
it("should update an existing Hec integration", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Hec,
configuration: JSON.stringify({ url, bearerToken, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ index, service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse);
mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
await service.updateHec(
organizationId,
integrationId,
configId,
serviceType,
url,
bearerToken,
index,
);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations.length).toBe(1);
expect(integrations[0].id).toBe(integrationId);
});
it("should throw error on organization ID mismatch in updateHec", async () => {
service.setOrganizationIntegrations("other-org" as OrganizationId);
await expect(
service.updateHec(
organizationId,
integrationId,
configId,
serviceType,
url,
bearerToken,
index,
),
).rejects.toThrow(Error("Organization ID mismatch"));
});
it("should get integration by id", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[],
),
]);
const integration = await service.getIntegrationById(integrationId);
expect(integration).not.toBeNull();
expect(integration!.id).toBe(integrationId);
});
it("should get integration by service type", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[],
),
]);
const integration = await service.getIntegrationByServiceType(serviceType);
expect(integration).not.toBeNull();
expect(integration!.serviceType).toBe(serviceType);
});
it("should get integration configurations", async () => {
const config = new OrganizationIntegrationConfiguration(
configId,
integrationId,
null,
null,
"",
{} as HecTemplate,
);
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[config],
),
]);
const configs = await service.getIntegrationConfigurations(integrationId);
expect(configs).not.toBeNull();
expect(configs![0].id).toBe(configId);
});
it("convertToJson should parse valid JSON", () => {
const obj = service.convertToJson<{ a: number }>('{"a":1}');
expect(obj).toEqual({ a: 1 });
});
it("convertToJson should return null for invalid JSON", () => {
const obj = service.convertToJson<{ a: number }>("invalid");
expect(obj).toBeNull();
});
});

View File

@@ -1,353 +0,0 @@
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import {
OrganizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
} from "@bitwarden/common/types/guid";
import { HecConfiguration } from "../models/configuration/hec-configuration";
import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
export type HecModificationFailureReason = {
mustBeOwner: boolean;
success: boolean;
};
export class HecOrganizationIntegrationService {
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
private destroy$ = new Subject<void>();
integrations$ = this._integrations$.asObservable();
private fetch$ = this.organizationId$
.pipe(
switchMap(async (orgId) => {
if (orgId) {
const data$ = await this.setIntegrations(orgId);
return await firstValueFrom(data$);
} else {
return [] as OrganizationIntegration[];
}
}),
takeUntil(this.destroy$),
)
.subscribe({
next: (integrations) => {
this._integrations$.next(integrations);
},
});
constructor(
private integrationApiService: OrganizationIntegrationApiService,
private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
) {}
/**
* Sets the organization Id and will trigger the retrieval of the
* integrations for a given org.
* @param orgId
*/
setOrganizationIntegrations(orgId: OrganizationId) {
if (orgId == this.organizationId$.getValue()) {
return;
}
this._integrations$.next([]);
this.organizationId$.next(orgId);
}
/**
* Saves a new organization integration and updates the integrations$ observable
* @param organizationId id of the organization
* @param service service type of the integration
* @param url url of the service
* @param bearerToken api token
* @param index index in service
*/
async saveHec(
organizationId: OrganizationId,
service: OrganizationIntegrationServiceType,
url: string,
bearerToken: string,
index: string,
): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const hecConfig = new HecConfiguration(url, bearerToken, service);
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
organizationId,
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
);
const newTemplate = new HecTemplate(index, service);
const newIntegrationConfigResponse =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId,
newIntegrationResponse.id,
new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()),
);
const newIntegration = this.mapResponsesToOrganizationIntegration(
newIntegrationResponse,
newIntegrationConfigResponse,
);
if (newIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Updates an existing organization integration and updates the integrations$ observable
* @param organizationId id of the organization
* @param OrganizationIntegrationId id of the organization integration
* @param OrganizationIntegrationConfigurationId id of the organization integration configuration
* @param service service type of the integration
* @param url url of the service
* @param bearerToken api token
* @param index index in service
*/
async updateHec(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
service: OrganizationIntegrationServiceType,
url: string,
bearerToken: string,
index: string,
): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const hecConfig = new HecConfiguration(url, bearerToken, service);
const updatedIntegrationResponse =
await this.integrationApiService.updateOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
);
const updatedTemplate = new HecTemplate(index, service);
const updatedIntegrationConfigResponse =
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
new OrganizationIntegrationConfigurationRequest(
null,
null,
null,
updatedTemplate.toString(),
),
);
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
updatedIntegrationResponse,
updatedIntegrationConfigResponse,
);
if (updatedIntegration !== null) {
const unchangedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next([...unchangedIntegrations, updatedIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
async deleteHec(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// delete the integration
await this.integrationApiService.deleteOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
);
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next(updatedIntegrations);
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Gets a OrganizationIntegration for an OrganizationIntegrationId
* @param integrationId id of the integration
* @returns OrganizationIntegration or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationById(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.id === integrationId) || null),
),
);
}
/**
* Gets a OrganizationIntegration for a service type
* @param serviceType type of the service
* @returns OrganizationIntegration or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationByServiceType(
serviceType: OrganizationIntegrationServiceType,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null),
),
);
}
/**
* Gets a OrganizationIntegrationConfigurations for an integration ID
* @param integrationId id of the integration
* @returns OrganizationIntegration array or null
*/
// TODO: Move to base class when another service integration type is implemented
async getIntegrationConfigurations(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegrationConfiguration[] | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => {
const integration = integrations.find((i) => i.id === integrationId);
return integration ? integration.integrationConfiguration : null;
}),
),
);
}
// TODO: Move to data models to be more explicit for future services
private mapResponsesToOrganizationIntegration(
integrationResponse: OrganizationIntegrationResponse,
configurationResponse: OrganizationIntegrationConfigurationResponse,
): OrganizationIntegration | null {
const hecConfig = this.convertToJson<HecConfiguration>(integrationResponse.configuration);
const template = this.convertToJson<HecTemplate>(configurationResponse.template);
if (!hecConfig || !template) {
return null;
}
const integrationConfig = new OrganizationIntegrationConfiguration(
configurationResponse.id,
integrationResponse.id,
null,
null,
"",
template,
);
return new OrganizationIntegration(
integrationResponse.id,
integrationResponse.type,
hecConfig.service,
hecConfig,
[integrationConfig],
);
}
// Could possibly be moved to a base service. All services would then assume that the
// integration configuration would always be an array and this hec specific service
// would just assume a single entry.
private setIntegrations(orgId: OrganizationId) {
const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe(
switchMap(([responses]) => {
const integrations: OrganizationIntegration[] = [];
const promises: Promise<void>[] = [];
responses.forEach((integration) => {
if (integration.type === OrganizationIntegrationType.Hec) {
const promise = this.integrationConfigurationApiService
.getOrganizationIntegrationConfigurations(orgId, integration.id)
.then((response) => {
// Hec events will only have one OrganizationIntegrationConfiguration
const config = response[0];
const orgIntegration = this.mapResponsesToOrganizationIntegration(
integration,
config,
);
if (orgIntegration !== null) {
integrations.push(orgIntegration);
}
});
promises.push(promise);
}
});
return Promise.all(promises).then(() => {
return integrations;
});
}),
);
return results$;
}
// TODO: Move to base service when necessary
convertToJson<T>(jsonString?: string): T | null {
try {
return JSON.parse(jsonString || "") as T;
} catch {
return null;
}
}
}

View File

@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
@@ -56,7 +56,7 @@ describe("OrganizationIntegrationApiService", () => {
it("should call apiService.send with correct parameters for createOrganizationIntegration", async () => {
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`,
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`,
);
const orgId = "org1" as OrganizationId;
@@ -76,7 +76,7 @@ describe("OrganizationIntegrationApiService", () => {
it("should call apiService.send with the correct parameters for updateOrganizationIntegration", async () => {
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`,
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`,
);
const orgId = "org1" as OrganizationId;
const integrationId = "integration1" as OrganizationIntegrationId;

View File

@@ -0,0 +1,633 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import {
OrganizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
} from "@bitwarden/common/types/guid";
import { OrgIntegrationBuilder } from "../models/integration-builder";
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
import { OrganizationIntegrationService } from "./organization-integration-service";
describe("OrganizationIntegrationService", () => {
let service: OrganizationIntegrationService;
let integrationApiService: MockProxy<OrganizationIntegrationApiService>;
let integrationConfigurationApiService: MockProxy<OrganizationIntegrationConfigurationApiService>;
const orgId = "org-123" as OrganizationId;
const integrationId = "integration-456" as OrganizationIntegrationId;
const configurationId = "config-789" as OrganizationIntegrationConfigurationId;
const mockIntegrationResponse = new OrganizationIntegrationResponse({
Id: integrationId,
Type: OrganizationIntegrationType.Hec,
Configuration: JSON.stringify({
uri: "https://test.splunk.com",
token: "test-token",
service: OrganizationIntegrationServiceName.CrowdStrike,
}),
});
const mockConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
Id: configurationId,
Template: JSON.stringify({
index: "main",
service: OrganizationIntegrationServiceName.CrowdStrike,
}),
});
beforeEach(() => {
integrationApiService = mock<OrganizationIntegrationApiService>();
integrationConfigurationApiService = mock<OrganizationIntegrationConfigurationApiService>();
service = new OrganizationIntegrationService(
integrationApiService,
integrationConfigurationApiService,
);
});
describe("initialization", () => {
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should initialize with empty integrations", async () => {
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toEqual([]);
});
});
describe("setOrganizationId", () => {
it("should fetch and set integrations for the organization", async () => {
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
Promise.resolve([mockConfigurationResponse]),
);
service.setOrganizationId(orgId).subscribe();
// Wait for the observable to emit
await new Promise((resolve) => setTimeout(resolve, 100));
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
expect(integrations[0].id).toBe(integrationId);
expect(integrations[0].type).toBe(OrganizationIntegrationType.Hec);
expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId);
expect(
integrationConfigurationApiService.getOrganizationIntegrationConfigurations,
).toHaveBeenCalledWith(orgId, integrationId);
});
it("should skip fetching if organization ID is the same", async () => {
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 50));
integrationApiService.getOrganizationIntegrations.mockClear();
// Call again with the same org ID
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(integrationApiService.getOrganizationIntegrations).not.toHaveBeenCalled();
});
it("should clear existing integrations when switching organizations", async () => {
const orgId2 = "org-456" as OrganizationId;
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
Promise.resolve([mockConfigurationResponse]),
);
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
let integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
// Switch to different org
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
service.setOrganizationId(orgId2).subscribe();
// Should immediately clear
integrations = await firstValueFrom(service.integrations$);
expect(integrations).toEqual([]);
});
it("should unsubscribe from previous fetch when setting new organization", async () => {
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 50));
const orgId2 = "org-456" as OrganizationId;
service.setOrganizationId(orgId2).subscribe();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should call the API for both organizations (no errors about duplicate subscriptions)
// The exact call count may vary based on observable behavior
expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalled();
});
it("should handle multiple integrations", async () => {
const integration2Response = new OrganizationIntegrationResponse({
Id: "integration-2" as OrganizationIntegrationId,
Type: OrganizationIntegrationType.Datadog,
Configuration: JSON.stringify({
uri: "https://datadog.com",
apiKey: "test-api-key",
service: OrganizationIntegrationServiceName.Datadog,
}),
});
const configuration2Response = new OrganizationIntegrationConfigurationResponse({
Id: "config-2" as OrganizationIntegrationConfigurationId,
Template: JSON.stringify({
service: OrganizationIntegrationServiceName.Datadog,
}),
});
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse, integration2Response]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations
.mockReturnValueOnce(Promise.resolve([mockConfigurationResponse]))
.mockReturnValueOnce(Promise.resolve([configuration2Response]));
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(2);
});
});
describe("save", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
"https://test.splunk.com",
"test-token",
OrganizationIntegrationServiceName.CrowdStrike,
);
const template = OrgIntegrationBuilder.buildHecTemplate(
"main",
OrganizationIntegrationServiceName.CrowdStrike,
);
beforeEach(() => {
// Set the organization first
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
service.setOrganizationId(orgId).subscribe();
});
it("should save a new integration successfully", async () => {
integrationApiService.createOrganizationIntegration.mockResolvedValue(
mockIntegrationResponse,
);
integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
mockConfigurationResponse,
);
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
expect(result).toEqual({ mustBeOwner: false, success: true });
expect(integrationApiService.createOrganizationIntegration).toHaveBeenCalledWith(
orgId,
expect.any(OrganizationIntegrationRequest),
);
expect(
integrationConfigurationApiService.createOrganizationIntegrationConfiguration,
).toHaveBeenCalledWith(
orgId,
integrationId,
expect.any(OrganizationIntegrationConfigurationRequest),
);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
expect(integrations[0].id).toBe(integrationId);
});
it("should throw error when organization ID mismatch", async () => {
const differentOrgId = "different-org" as OrganizationId;
await expect(
service.save(differentOrgId, OrganizationIntegrationType.Hec, config, template),
).rejects.toThrow("Organization ID mismatch");
});
it("should return mustBeOwner true when API returns 404", async () => {
const error = new ErrorResponse({}, 404);
integrationApiService.createOrganizationIntegration.mockRejectedValue(error);
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
expect(result).toEqual({ mustBeOwner: true, success: false });
});
it("should rethrow non-404 errors", async () => {
const error = new Error("Server error");
integrationApiService.createOrganizationIntegration.mockRejectedValue(error);
await expect(
service.save(orgId, OrganizationIntegrationType.Hec, config, template),
).rejects.toThrow("Server error");
});
it("should handle configuration creation failure with 404", async () => {
const error = new ErrorResponse({}, 404);
integrationApiService.createOrganizationIntegration.mockResolvedValue(
mockIntegrationResponse,
);
integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockRejectedValue(
error,
);
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
expect(result).toEqual({ mustBeOwner: true, success: false });
});
});
describe("update", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
"https://updated.splunk.com",
"updated-token",
OrganizationIntegrationServiceName.CrowdStrike,
);
const template = OrgIntegrationBuilder.buildHecTemplate(
"updated-index",
OrganizationIntegrationServiceName.CrowdStrike,
);
beforeEach(() => {
// Set the organization and add an existing integration
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
Promise.resolve([mockConfigurationResponse]),
);
service.setOrganizationId(orgId).subscribe();
});
it("should update an integration successfully", async () => {
const updatedIntegrationResponse = new OrganizationIntegrationResponse({
Id: integrationId,
Type: OrganizationIntegrationType.Hec,
Configuration: JSON.stringify({
uri: "https://updated.splunk.com",
token: "updated-token",
service: OrganizationIntegrationServiceName.CrowdStrike,
}),
});
const updatedConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
Id: configurationId,
Template: JSON.stringify({
index: "updated-index",
service: OrganizationIntegrationServiceName.CrowdStrike,
}),
});
integrationApiService.updateOrganizationIntegration.mockResolvedValue(
updatedIntegrationResponse,
);
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
updatedConfigurationResponse,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await service.update(
orgId,
integrationId,
OrganizationIntegrationType.Hec,
configurationId,
config,
template,
);
expect(result).toEqual({ mustBeOwner: false, success: true });
expect(integrationApiService.updateOrganizationIntegration).toHaveBeenCalledWith(
orgId,
integrationId,
expect.any(OrganizationIntegrationRequest),
);
expect(
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration,
).toHaveBeenCalledWith(
orgId,
integrationId,
configurationId,
expect.any(OrganizationIntegrationConfigurationRequest),
);
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
expect(integrations[0].id).toBe(integrationId);
});
it("should throw error when organization ID mismatch", async () => {
const differentOrgId = "different-org" as OrganizationId;
await expect(
service.update(
differentOrgId,
integrationId,
OrganizationIntegrationType.Hec,
configurationId,
config,
template,
),
).rejects.toThrow("Organization ID mismatch");
});
it("should return mustBeOwner true when API returns 404", async () => {
const error = new ErrorResponse({}, 404);
integrationApiService.updateOrganizationIntegration.mockRejectedValue(error);
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await service.update(
orgId,
integrationId,
OrganizationIntegrationType.Hec,
configurationId,
config,
template,
);
expect(result).toEqual({ mustBeOwner: true, success: false });
});
it("should rethrow non-404 errors", async () => {
const error = new Error("Server error");
integrationApiService.updateOrganizationIntegration.mockRejectedValue(error);
await new Promise((resolve) => setTimeout(resolve, 100));
await expect(
service.update(
orgId,
integrationId,
OrganizationIntegrationType.Hec,
configurationId,
config,
template,
),
).rejects.toThrow("Server error");
});
it("should replace old integration with updated one in the list", async () => {
// Add multiple integrations first
const integration2Response = new OrganizationIntegrationResponse({
Id: "integration-2" as OrganizationIntegrationId,
Type: OrganizationIntegrationType.Hec,
Configuration: mockIntegrationResponse.configuration,
});
const configuration2Response = new OrganizationIntegrationConfigurationResponse({
Id: "config-2" as OrganizationIntegrationConfigurationId,
Template: mockConfigurationResponse.template,
});
const orgId2 = "org-456" as OrganizationId;
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse, integration2Response]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations
.mockReturnValue(Promise.resolve([mockConfigurationResponse]))
.mockReturnValueOnce(Promise.resolve([mockConfigurationResponse]))
.mockReturnValueOnce(Promise.resolve([configuration2Response]));
service.setOrganizationId(orgId2).subscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
let integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(2);
// Now update the first integration
integrationApiService.updateOrganizationIntegration.mockResolvedValue(
mockIntegrationResponse,
);
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
mockConfigurationResponse,
);
await service.update(
orgId2,
integrationId,
OrganizationIntegrationType.Hec,
configurationId,
config,
template,
);
integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(2);
expect(integrations.find((i) => i.id === integrationId)).toBeDefined();
expect(integrations.find((i) => i.id === "integration-2")).toBeDefined();
});
});
describe("delete", () => {
beforeEach(() => {
// Set the organization and add an existing integration
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([mockIntegrationResponse]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
Promise.resolve([mockConfigurationResponse]),
);
service.setOrganizationId(orgId).subscribe();
});
it("should delete an integration successfully", async () => {
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue(
undefined,
);
integrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined);
await new Promise((resolve) => setTimeout(resolve, 100));
let integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
const result = await service.delete(orgId, integrationId, configurationId);
expect(result).toEqual({ mustBeOwner: false, success: true });
expect(
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration,
).toHaveBeenCalledWith(orgId, integrationId, configurationId);
expect(integrationApiService.deleteOrganizationIntegration).toHaveBeenCalledWith(
orgId,
integrationId,
);
integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(0);
});
it("should delete configuration before integration", async () => {
const callOrder: string[] = [];
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockImplementation(
async () => {
callOrder.push("configuration");
},
);
integrationApiService.deleteOrganizationIntegration.mockImplementation(async () => {
callOrder.push("integration");
});
await new Promise((resolve) => setTimeout(resolve, 100));
await service.delete(orgId, integrationId, configurationId);
expect(callOrder).toEqual(["configuration", "integration"]);
});
it("should throw error when organization ID mismatch", async () => {
const differentOrgId = "different-org" as OrganizationId;
await expect(service.delete(differentOrgId, integrationId, configurationId)).rejects.toThrow(
"Organization ID mismatch",
);
});
it("should return mustBeOwner true when API returns 404", async () => {
const error = new ErrorResponse({}, 404);
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue(
error,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await service.delete(orgId, integrationId, configurationId);
expect(result).toEqual({ mustBeOwner: true, success: false });
});
it("should rethrow non-404 errors", async () => {
const error = new Error("Server error");
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue(
error,
);
await new Promise((resolve) => setTimeout(resolve, 100));
await expect(service.delete(orgId, integrationId, configurationId)).rejects.toThrow(
"Server error",
);
});
it("should handle 404 error when deleting integration", async () => {
const error = new ErrorResponse({}, 404);
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue(
undefined,
);
integrationApiService.deleteOrganizationIntegration.mockRejectedValue(error);
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await service.delete(orgId, integrationId, configurationId);
expect(result).toEqual({ mustBeOwner: true, success: false });
});
});
describe("mapResponsesToOrganizationIntegration", () => {
it("should return null if configuration cannot be built", () => {
const invalidIntegrationResponse = new OrganizationIntegrationResponse({
Id: integrationId,
Type: 999 as OrganizationIntegrationType, // Invalid type
Configuration: "invalid-json",
});
// The buildConfiguration method throws for unsupported types
// In production, this error is caught in the setIntegrations pipeline
expect(() =>
service["mapResponsesToOrganizationIntegration"](
invalidIntegrationResponse,
mockConfigurationResponse,
),
).toThrow("Unsupported integration type: 999");
});
it("should handle template with invalid data", () => {
const invalidConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
Id: configurationId,
Template: "{}", // Empty template, will have undefined values but won't return null
});
const result = service["mapResponsesToOrganizationIntegration"](
mockIntegrationResponse,
invalidConfigurationResponse,
);
// The result won't be null, but will have a template with undefined/default values
expect(result).not.toBeNull();
expect(result?.integrationConfiguration[0].template).toBeDefined();
});
it("should successfully map valid responses to OrganizationIntegration", () => {
const result = service["mapResponsesToOrganizationIntegration"](
mockIntegrationResponse,
mockConfigurationResponse,
);
expect(result).not.toBeNull();
expect(result?.id).toBe(integrationId);
expect(result?.type).toBe(OrganizationIntegrationType.Hec);
expect(result?.integrationConfiguration).toHaveLength(1);
expect(result?.integrationConfiguration[0].id).toBe(configurationId);
});
});
describe("edge cases", () => {
it("should handle empty integration list from API", async () => {
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toEqual([]);
});
it("should handle errors when fetching integrations", async () => {
const validIntegration = mockIntegrationResponse;
integrationApiService.getOrganizationIntegrations.mockReturnValue(
Promise.resolve([validIntegration]),
);
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
Promise.resolve([mockConfigurationResponse]),
);
service.setOrganizationId(orgId).subscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
const integrations = await firstValueFrom(service.integrations$);
expect(integrations).toHaveLength(1);
expect(integrations[0].id).toBe(integrationId);
});
});
});

View File

@@ -0,0 +1,313 @@
import { BehaviorSubject, map, Observable, of, switchMap, tap, zip } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import {
OrganizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
} from "@bitwarden/common/types/guid";
import {
OrgIntegrationBuilder,
OrgIntegrationConfiguration,
OrgIntegrationTemplate,
} from "../models/integration-builder";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
/**
* Common result type for integration modification operations (save, update, delete).
* was the server side failure due to insufficient permissions (must be owner)?
*/
export type IntegrationModificationResult = {
mustBeOwner: boolean;
success: boolean;
};
/**
* Provides common functionality for managing integrations with different external services.
*/
export class OrganizationIntegrationService {
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
integrations$: Observable<OrganizationIntegration[]> = this._integrations$.asObservable();
constructor(
protected integrationApiService: OrganizationIntegrationApiService,
protected integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
) {}
/**
* Sets the organization Id and triggers the retrieval of integrations for the given organization.
* The integrations will be available via the integrations$ observable.
* If the organization ID is the same as the current one, no action is taken.
* Use this method to kick off loading integrations for a specific organization.
* Use integrations$ to subscribe to the loaded integrations.
*
* @param orgId - The organization ID to set
* @returns Observable<void> that completes when the operation is done. Subscribe to trigger the load.
*/
setOrganizationId(orgId: OrganizationId): Observable<void> {
if (orgId === this.organizationId$.getValue()) {
return of(void 0);
}
this._integrations$.next([]);
this.organizationId$.next(orgId);
// subscribe to load and set integrations
// use integrations$ to get the loaded integrations
return this.setIntegrations(orgId).pipe(
tap((integrations) => {
this._integrations$.next(integrations);
}),
map((): void => void 0),
);
}
/**
* Saves a new organization integration and updates the integrations$ observable.
*
* @param organizationId - ID of the organization
* @param integrationType - Type of the organization integration
* @param config - The configuration object for this integration
* @param template - The template object for this integration
* @returns Promise with the result indicating success or failure reason
*/
async save(
organizationId: OrganizationId,
integrationType: OrganizationIntegrationType,
config: OrgIntegrationConfiguration,
template: OrgIntegrationTemplate,
): Promise<IntegrationModificationResult> {
if (organizationId !== this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const configString = config.toString();
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
organizationId,
new OrganizationIntegrationRequest(integrationType, configString),
);
const templateString = template.toString();
const newIntegrationConfigResponse =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId,
newIntegrationResponse.id,
new OrganizationIntegrationConfigurationRequest(null, null, null, templateString),
);
const newIntegration = this.mapResponsesToOrganizationIntegration(
newIntegrationResponse,
newIntegrationConfigResponse,
);
if (newIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Updates an existing organization integration and updates the integrations$ observable.
*
* @param organizationId - ID of the organization
* @param integrationId - ID of the organization integration
* @param integrationType - Type of the organization integration
* @param configurationId - ID of the organization integration configuration
* @param config - The updated configuration object
* @param template - The updated template object
* @returns Promise with the result indicating success or failure reason
*/
async update(
organizationId: OrganizationId,
integrationId: OrganizationIntegrationId,
integrationType: OrganizationIntegrationType,
configurationId: OrganizationIntegrationConfigurationId,
config: OrgIntegrationConfiguration,
template: OrgIntegrationTemplate,
): Promise<IntegrationModificationResult> {
if (organizationId !== this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
const configString = config.toString();
const updatedIntegrationResponse =
await this.integrationApiService.updateOrganizationIntegration(
organizationId,
integrationId,
new OrganizationIntegrationRequest(integrationType, configString),
);
const templateString = template.toString();
const updatedIntegrationConfigResponse =
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
organizationId,
integrationId,
configurationId,
new OrganizationIntegrationConfigurationRequest(null, null, null, templateString),
);
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
updatedIntegrationResponse,
updatedIntegrationConfigResponse,
);
if (updatedIntegration !== null) {
const integrations = this._integrations$.getValue();
const index = integrations.findIndex((i) => i.id === integrationId);
if (index !== -1) {
integrations[index] = updatedIntegration;
} else {
integrations.push(updatedIntegration);
}
this._integrations$.next([...integrations]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Deletes an organization integration and updates the integrations$ observable.
*
* @param organizationId - ID of the organization
* @param integrationId - ID of the organization integration
* @param configurationId - ID of the organization integration configuration
* @returns Promise with the result indicating success or failure reason
*/
async delete(
organizationId: OrganizationId,
integrationId: OrganizationIntegrationId,
configurationId: OrganizationIntegrationConfigurationId,
): Promise<IntegrationModificationResult> {
if (organizationId !== this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
try {
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
integrationId,
configurationId,
);
// delete the integration
await this.integrationApiService.deleteOrganizationIntegration(organizationId, integrationId);
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== integrationId);
this._integrations$.next(updatedIntegrations);
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
}
/**
* Maps API responses to an OrganizationIntegration domain model.
*
* @param integrationResponse - The integration response from the API
* @param configurationResponse - The configuration response from the API
* @returns OrganizationIntegration or null if mapping fails
*/
private mapResponsesToOrganizationIntegration(
integrationResponse: OrganizationIntegrationResponse,
configurationResponse: OrganizationIntegrationConfigurationResponse,
): OrganizationIntegration | null {
const integrationType = integrationResponse.type;
const config = OrgIntegrationBuilder.buildConfiguration(
integrationType,
integrationResponse.configuration,
);
const template = OrgIntegrationBuilder.buildTemplate(
integrationType,
configurationResponse.template ?? "{}",
);
if (!config || !template) {
return null;
}
const integrationConfig = new OrganizationIntegrationConfiguration(
configurationResponse.id,
integrationResponse.id,
null,
"",
template,
);
return new OrganizationIntegration(
integrationResponse.id,
integrationResponse.type,
config.service,
config,
[integrationConfig],
);
}
/**
* Fetches integrations for the given organization from the API.
*
* @param orgId - Organization ID to fetch integrations for
* @returns Observable of OrganizationIntegration array
*/
private setIntegrations(orgId: OrganizationId): Observable<OrganizationIntegration[]> {
const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe(
switchMap(([responses]) => {
const integrations: OrganizationIntegration[] = [];
const promises: Promise<void>[] = [];
responses.forEach((integration) => {
const promise = this.integrationConfigurationApiService
.getOrganizationIntegrationConfigurations(orgId, integration.id)
.then((response) => {
// Integration will only have one OrganizationIntegrationConfiguration
const config = response[0];
const orgIntegration = this.mapResponsesToOrganizationIntegration(
integration,
config,
);
if (orgIntegration !== null) {
integrations.push(orgIntegration);
}
});
promises.push(promise);
});
return Promise.all(promises).then(() => {
return integrations;
});
}),
);
return results$;
}
}

View File

@@ -10,6 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
@@ -41,9 +42,10 @@ describe("Provider Permissions Guard", () => {
accountService.activeAccount$ = of({
id: mockUserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
route = mock<ActivatedRouteSnapshot>({

View File

@@ -143,16 +143,14 @@ export class AllApplicationsComponent implements OnInit {
onCheckboxChange = (applicationName: string, event: Event) => {
const isChecked = (event.target as HTMLInputElement).checked;
if (isChecked) {
this.selectedUrls.update((selectedUrls) => {
selectedUrls.add(applicationName);
return selectedUrls;
});
} else {
this.selectedUrls.update((selectedUrls) => {
selectedUrls.delete(applicationName);
return selectedUrls;
});
}
this.selectedUrls.update((selectedUrls) => {
const nextSelected = new Set(selectedUrls);
if (isChecked) {
nextSelected.add(applicationName);
} else {
nextSelected.delete(applicationName);
}
return nextSelected;
});
};
}

View File

@@ -4,9 +4,10 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
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 { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
@@ -29,8 +30,7 @@ describe("IntegrationCardComponent", () => {
let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>();
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
const mockDatadogIntegrationService = mock<DatadogOrganizationIntegrationService>();
const mockIntegrationService = mock<OrganizationIntegrationService>();
const dialogService = mock<DialogService>();
const toastService = mock<ToastService>();
@@ -54,8 +54,7 @@ describe("IntegrationCardComponent", () => {
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
{ provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService },
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
{ provide: ToastService, useValue: toastService },
{ provide: DialogService, useValue: dialogService },
],
@@ -259,7 +258,7 @@ describe("IntegrationCardComponent", () => {
configuration: {},
integrationConfiguration: [{ id: "config-id" }],
},
name: OrganizationIntegrationServiceType.CrowdStrike,
name: OrganizationIntegrationServiceName.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
jest.resetAllMocks();
@@ -270,8 +269,8 @@ describe("IntegrationCardComponent", () => {
closed: of({ success: false }),
});
await component.setupConnection();
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
expect(mockIntegrationService.update).not.toHaveBeenCalled();
expect(mockIntegrationService.save).not.toHaveBeenCalled();
});
it("should call updateHec if isUpdateAvailable is true", async () => {
@@ -284,26 +283,35 @@ describe("IntegrationCardComponent", () => {
}),
});
const config = OrgIntegrationBuilder.buildHecConfiguration(
"test-url",
"token",
OrganizationIntegrationServiceName.CrowdStrike,
);
const template = OrgIntegrationBuilder.buildHecTemplate(
"index",
OrganizationIntegrationServiceName.CrowdStrike,
);
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalledWith(
expect(mockIntegrationService.update).toHaveBeenCalledWith(
"org-id",
"integration-id",
OrganizationIntegrationType.Hec,
"config-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
config,
template,
);
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
expect(mockIntegrationService.save).not.toHaveBeenCalled();
});
it("should call saveHec if isUpdateAvailable is false", async () => {
component.integrationSettings = {
organizationIntegration: null,
name: OrganizationIntegrationServiceType.CrowdStrike,
name: OrganizationIntegrationServiceName.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
@@ -316,23 +324,32 @@ describe("IntegrationCardComponent", () => {
}),
});
const config = OrgIntegrationBuilder.buildHecConfiguration(
"test-url",
"token",
OrganizationIntegrationServiceName.CrowdStrike,
);
const template = OrgIntegrationBuilder.buildHecTemplate(
"index",
OrganizationIntegrationServiceName.CrowdStrike,
);
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false);
mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true });
mockIntegrationService.save.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection();
expect(mockIntegrationService.saveHec).toHaveBeenCalledWith(
expect(mockIntegrationService.save).toHaveBeenCalledWith(
"org-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
OrganizationIntegrationType.Hec,
config,
template,
);
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
expect(mockIntegrationService.update).not.toHaveBeenCalled();
});
it("should call deleteHec when a delete is requested", async () => {
it("should call delete with Hec type when a delete is requested", async () => {
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({
@@ -344,22 +361,22 @@ describe("IntegrationCardComponent", () => {
}),
});
mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalledWith(
expect(mockIntegrationService.delete).toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
);
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
expect(mockIntegrationService.save).not.toHaveBeenCalled();
});
it("should not call deleteHec if no existing configuration", async () => {
it("should not call delete if no existing configuration", async () => {
component.integrationSettings = {
organizationIntegration: null,
name: OrganizationIntegrationServiceType.CrowdStrike,
name: OrganizationIntegrationServiceName.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
@@ -372,20 +389,16 @@ describe("IntegrationCardComponent", () => {
}),
});
mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection();
expect(mockIntegrationService.deleteHec).not.toHaveBeenCalledWith(
expect(mockIntegrationService.delete).not.toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
);
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
expect(mockIntegrationService.update).not.toHaveBeenCalled();
});
it("should show toast on error while saving", async () => {
@@ -399,11 +412,11 @@ describe("IntegrationCardComponent", () => {
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new Error("fail"));
mockIntegrationService.update.mockRejectedValue(new Error("fail"));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(mockIntegrationService.update).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
@@ -422,11 +435,11 @@ describe("IntegrationCardComponent", () => {
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(mockIntegrationService.update).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
@@ -445,11 +458,10 @@ describe("IntegrationCardComponent", () => {
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(mockIntegrationService.update).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
@@ -468,11 +480,11 @@ describe("IntegrationCardComponent", () => {
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.deleteHec.mockRejectedValue(new Error("fail"));
mockIntegrationService.delete.mockRejectedValue(new Error("fail"));
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
expect(mockIntegrationService.delete).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
@@ -491,11 +503,10 @@ describe("IntegrationCardComponent", () => {
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
mockIntegrationService.delete.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
expect(mockIntegrationService.delete).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",

View File

@@ -12,10 +12,10 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
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 { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@@ -96,8 +96,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService,
private activatedRoute: ActivatedRoute,
private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService,
private organizationIntegrationService: OrganizationIntegrationService,
private toastService: ToastService,
private i18nService: I18nService,
) {
@@ -250,7 +249,18 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async saveHec(result: HecConnectDialogResult) {
let saveResponse = { mustBeOwner: false, success: false };
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildHecConfiguration(
result.url,
result.bearerToken,
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
const template = OrgIntegrationBuilder.buildHecTemplate(
result.index,
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
@@ -262,27 +272,25 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
// update existing integration and configuration
saveResponse = await this.hecOrganizationIntegrationService.updateHec(
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Hec,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
config,
template,
);
} else {
// create new integration and configuration
saveResponse = await this.hecOrganizationIntegrationService.saveHec(
response = await this.organizationIntegrationService.save(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
OrganizationIntegrationType.Hec,
config,
template,
);
}
if (saveResponse.mustBeOwner) {
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
@@ -303,7 +311,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.hecOrganizationIntegrationService.deleteHec(
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
@@ -322,6 +330,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async saveDatadog(result: DatadogConnectDialogResult) {
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey);
const template = OrgIntegrationBuilder.buildDataDogTemplate(
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
@@ -333,23 +348,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
// update existing integration and configuration
await this.datadogOrganizationIntegrationService.updateDatadog(
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Datadog,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.apiKey,
config,
template,
);
} else {
// create new integration and configuration
await this.datadogOrganizationIntegrationService.saveDatadog(
response = await this.organizationIntegrationService.save(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.apiKey,
OrganizationIntegrationType.Datadog,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
@@ -366,7 +387,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.datadogOrganizationIntegrationService.deleteDatadog(
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,

View File

@@ -6,8 +6,7 @@ import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
@@ -24,8 +23,7 @@ describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>();
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
const mockDatadogIntegrationService = mock<DatadogOrganizationIntegrationService>();
const mockIntegrationService = mock<OrganizationIntegrationService>();
const integrations: Integration[] = [
{
name: "Integration 1",
@@ -71,8 +69,7 @@ describe("IntegrationGridComponent", () => {
provide: ActivatedRoute,
useValue: mockActivatedRoute,
},
{ provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
{ provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService },
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
{
provide: ToastService,
useValue: mock<ToastService>(),

View File

@@ -1,69 +1,78 @@
<app-header> </app-header>
<bit-tab-group [(selectedIndex)]="tabIndex" *ngIf="organization$ | async as organization">
<bit-tab [label]="'singleSignOn' | i18n" *ngIf="organization.useSso">
<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>
@let organization = organization$ | async;
<bit-tab
[label]="'userProvisioning' | i18n"
*ngIf="organization.useScim || organization.useDirectory"
>
<section class="tw-mb-9" *ngIf="organization.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="organization.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>
</bit-tab>
@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>
}
<bit-tab [label]="'eventManagement' | i18n" *ngIf="organization.useEvents">
<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>
@if (organization?.useScim || organization?.useDirectory) {
<bit-tab [label]="'userProvisioning' | i18n">
<section class="tw-mb-9" *ngIf="organization?.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="organization?.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>
</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>
@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>
}

View File

@@ -3,10 +3,9 @@ import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
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 { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
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";
@@ -21,6 +20,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
import { FilterIntegrationsPipe } from "./integrations.pipe";
// 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({
@@ -236,10 +236,12 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
);
// Sets the organization ID which also loads the integrations$
this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id);
});
this.organization$
.pipe(
switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)),
takeUntil(this.destroy$),
)
.subscribe();
}
constructor(
@@ -247,8 +249,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService,
private organizationIntegrationService: OrganizationIntegrationService,
) {
this.configService
.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike)
@@ -260,7 +261,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// Add the new event based items to the list
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
const crowdstrikeIntegration: Integration = {
name: OrganizationIntegrationServiceType.CrowdStrike,
name: OrganizationIntegrationServiceName.CrowdStrike,
linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT,
@@ -272,7 +273,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.integrationsList.push(crowdstrikeIntegration);
const datadogIntegration: Integration = {
name: OrganizationIntegrationServiceType.Datadog,
name: OrganizationIntegrationServiceName.Datadog,
linkURL: "https://bitwarden.com/help/datadog-siem/",
image: "../../../../../../../images/integrations/logo-datadog-color.svg",
type: IntegrationType.EVENT,
@@ -286,42 +287,23 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// For all existing event based configurations loop through and assign the
// organizationIntegration for the correct services.
this.hecOrganizationIntegrationService.integrations$
this.organizationIntegrationService.integrations$
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// reset all integrations to null first - in case one was deleted
// reset all event based integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
if (i.integrationType === OrganizationIntegrationType.Hec) {
i.organizationIntegration = null;
}
i.organizationIntegration = null;
});
integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
if (item) {
item.organizationIntegration = integration;
}
});
});
this.datadogOrganizationIntegrationService.integrations$
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// reset all integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
if (i.integrationType === OrganizationIntegrationType.Datadog) {
i.organizationIntegration = null;
}
});
integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
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();

View File

@@ -1,9 +1,8 @@
import { NgModule } from "@angular/core";
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
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";
@@ -14,13 +13,8 @@ import { OrganizationIntegrationsRoutingModule } from "./organization-integratio
imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
providers: [
safeProvider({
provide: DatadogOrganizationIntegrationService,
useClass: DatadogOrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({
provide: HecOrganizationIntegrationService,
useClass: HecOrganizationIntegrationService,
provide: OrganizationIntegrationService,
useClass: OrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({

View File

@@ -9,8 +9,7 @@ import {} from "@bitwarden/web-vault/app/shared";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@@ -41,8 +40,7 @@ class MockNewMenuComponent {}
describe("IntegrationsComponent", () => {
let fixture: ComponentFixture<IntegrationsComponent>;
const hecOrgIntegrationSvc = mock<HecOrganizationIntegrationService>();
const datadogOrgIntegrationSvc = mock<DatadogOrganizationIntegrationService>();
const orgIntegrationSvc = mock<OrganizationIntegrationService>();
const activatedRouteMock = {
snapshot: { paramMap: { get: jest.fn() } },
@@ -60,8 +58,7 @@ describe("IntegrationsComponent", () => {
{ provide: ActivatedRoute, useValue: activatedRouteMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc },
{ provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc },
{ provide: OrganizationIntegrationService, useValue: orgIntegrationSvc },
],
}).compileComponents();
fixture = TestBed.createComponent(IntegrationsComponent);

View File

@@ -1,9 +1,8 @@
import { NgModule } from "@angular/core";
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
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";
@@ -23,13 +22,8 @@ import { IntegrationsComponent } from "./integrations.component";
],
providers: [
safeProvider({
provide: DatadogOrganizationIntegrationService,
useClass: DatadogOrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({
provide: HecOrganizationIntegrationService,
useClass: HecOrganizationIntegrationService,
provide: OrganizationIntegrationService,
useClass: OrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({

View File

@@ -52,12 +52,12 @@
[relativeTo]="route.parent"
>
<bit-nav-item
[text]="'importData' | i18n"
[text]="'import' | i18n"
route="settings/import"
[relativeTo]="route.parent"
></bit-nav-item>
<bit-nav-item
[text]="'exportData' | i18n"
[text]="'export' | i18n"
route="settings/export"
[relativeTo]="route.parent"
></bit-nav-item>

View File

@@ -6,6 +6,7 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
@@ -37,9 +38,11 @@ describe("SecretService", () => {
let accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
emailVerified: true,
}),
});
beforeEach(() => {

View File

@@ -17,6 +17,6 @@
</bit-form-field>
<button bitButton bitFormButton type="submit" buttonType="primary">
{{ "exportData" | i18n }}
{{ "export" | i18n }}
</button>
</form>

View File

@@ -124,7 +124,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy {
const ref = openUserVerificationPrompt(this.dialogService, {
data: {
confirmDescription: "exportSecretsWarningDesc",
confirmButtonText: "exportSecrets",
confirmButtonText: "export",
modalTitle: "confirmSecretsExport",
},
});

View File

@@ -36,6 +36,6 @@
<bit-hint>{{ "acceptedFormats" | i18n }} Bitwarden (json)</bit-hint>
</bit-form-field>
<button bitButton bitFormButton type="submit" buttonType="primary">
{{ "importData" | i18n }}
{{ "import" | i18n }}
</button>
</form>

View File

@@ -7,6 +7,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
@@ -38,9 +39,11 @@ describe("SecretsManagerPortingApiService", () => {
let accountService: MockProxy<AccountService>;
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
emailVerified: true,
}),
});
beforeEach(() => {

View File

@@ -12,7 +12,7 @@ const routes: Routes = [
component: SecretsManagerImportComponent,
canActivate: [organizationPermissionsGuard((org) => org.isAdmin)],
data: {
titleId: "importData",
titleId: "import",
},
},
{
@@ -20,7 +20,7 @@ const routes: Routes = [
component: SecretsManagerExportComponent,
canActivate: [organizationPermissionsGuard((org) => org.isAdmin)],
data: {
titleId: "exportData",
titleId: "export",
},
},
];

Some files were not shown because too many files have changed in this diff Show More