diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 96e16776545..ca57ccf4f86 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -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:", diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6978edd8b3c..efb94e44c7a 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -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' diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7f87a1e5628..2239cb1268f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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' }} diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 09ea964823c..2ace8ff8b96 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index 4bacd453803..f3be535f00e 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -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 = {}; const seedStatuses: Record = {}; 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({ diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index b5d725b4a82..bb6b141c6c5 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -128,6 +128,20 @@ + + +

{{ "phishingBlocker" | i18n }}

+
+ + + {{ + "enablePhishingDetection" | i18n + }} + {{ "enablePhishingDetectionDesc" | i18n }} + + +
+

{{ "otherOptions" | i18n }}

diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index d0ab4793301..0f799fe7d4d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -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; - const mockUserId = Utils.newGuid() as UserId; + const mockUserId = newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); - const vaultTimeoutSettingsService = mock(); + const apiService = mock(); + const billingService = mock(); const biometricStateService = mock(); - const policyService = mock(); - const pinServiceAbstraction = mock(); - const keyService = mock(); - const validationService = mock(); - const dialogService = mock(); - const platformUtilsService = mock(); - const lockService = mock(); const configService = mock(); + const dialogService = mock(); + const keyService = mock(); + const lockService = mock(); + const policyService = mock(); + const phishingDetectionSettingsService = mock(); + const pinServiceAbstraction = mock(); + const platformUtilsService = mock(); + const validationService = mock(); + const vaultNudgesService = mock(); + const vaultTimeoutSettingsService = mock(); + + // Mock subjects to control the phishing detection observables + let phishingAvailableSubject: BehaviorSubject; + let phishingEnabledSubject: BehaviorSubject; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -73,29 +86,38 @@ describe("AccountSecurityComponent", () => { { provide: AccountService, useValue: accountService }, { provide: AccountSecurityComponent, useValue: mock() }, { provide: ActivatedRoute, useValue: mock() }, + { provide: ApiService, useValue: apiService }, + { + provide: BillingAccountProfileStateService, + useValue: billingService, + }, { provide: BiometricsService, useValue: mock() }, { provide: BiometricStateService, useValue: biometricStateService }, + { provide: CipherService, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: EnvironmentService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: MessageSender, useValue: mock() }, { provide: KeyService, useValue: keyService }, + { provide: LockService, useValue: lockService }, + { provide: LogService, useValue: mock() }, + { provide: MessageSender, useValue: mock() }, + { provide: NudgesService, useValue: vaultNudgesService }, + { provide: OrganizationService, useValue: mock() }, { provide: PinServiceAbstraction, useValue: pinServiceAbstraction }, + { + provide: PhishingDetectionSettingsServiceAbstraction, + useValue: phishingDetectionSettingsService, + }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: PolicyService, useValue: policyService }, { provide: PopupRouterCacheService, useValue: mock() }, + { provide: StateProvider, useValue: mock() }, { provide: ToastService, useValue: mock() }, { provide: UserVerificationService, useValue: mock() }, - { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, - { provide: StateProvider, useValue: mock() }, - { provide: CipherService, useValue: mock() }, - { provide: ApiService, useValue: mock() }, - { provide: LogService, useValue: mock() }, - { provide: OrganizationService, useValue: mock() }, - { provide: CollectionService, useValue: mock() }, { 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({ + 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(true); + phishingEnabledSubject = new BehaviorSubject(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; diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 4ff29c8853e..7c36754c894 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -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 = @@ -141,6 +145,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ); protected readonly consolidatedSessionTimeoutComponent$: Observable; + protected readonly phishingDetectionAvailable$: Observable; protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); @@ -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(() => diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 8df21bc66ef..ab16788ea6f 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -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(); 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(() => { diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 1348928b7e9..1738485f289 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -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, + }), }); }); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 2540571abb0..7b509380f6d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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, ); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts index e33b4b1b4f1..ceb18bd1573 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts @@ -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; let messageListener: MockProxy; + let phishingDetectionSettingsService: MockProxy; 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({ @@ -29,16 +22,17 @@ describe("PhishingDetectionService", () => { return new Observable(); }, }); + phishingDetectionSettingsService = mock({ + 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, // ); // }); }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 4917e740be8..e04d08559ab 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -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) => { diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index abb7c6405c2..6e5218c9f27 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -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; } diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index dcd0496ed30..7a1815b86ed 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -1,31 +1,21 @@ - -
- -
- - - - - -
- +
+ + @if (showAcctSwitcher && hasLoggedInAccount) { + + } +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 7a30e15582c..8fdae06e28a 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -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 diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index bb89eff1147..39c53b7da56 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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>>, ngZone: NgZone) => diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6d79f430a37..6e73d9811f2 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -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", + }), }), }, }, diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html index d6bf3a3a253..5473bbe620e 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@ bitFormButton buttonType="primary" > - {{ "exportVault" | i18n }} + {{ "export" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts index 295496c701f..29282d293de 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts @@ -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); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index 459b328c44e..e9636e09873 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -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"); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 225640137e8..c042af8cbac 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -15,7 +15,7 @@ diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 70e9a8fd232..50ef414ec37 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -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; diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index e267e28a08c..54a6dba8326 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -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(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index f542e23129a..6fb6e6134c7 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -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", diff --git a/apps/desktop/desktop_native/napi/package.json b/apps/desktop/desktop_native/napi/package.json index 5401207c252..0717bfd53ea 100644 --- a/apps/desktop/desktop_native/napi/package.json +++ b/apps/desktop/desktop_native/napi/package.json @@ -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": "", diff --git a/apps/desktop/desktop_native/napi/scripts/build.js b/apps/desktop/desktop_native/napi/scripts/build.js new file mode 100644 index 00000000000..ad24b99d2fb --- /dev/null +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -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' } + }); +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 25dfdd08336..fe084349501 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -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>) { 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(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e85d34cebc..97ab8585a69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.0", + "version": "2025.12.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index e4dd144fa20..59021a556e4 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -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, diff --git a/apps/desktop/src/app/tools/export/export-desktop.component.html b/apps/desktop/src/app/tools/export/export-desktop.component.html index 9aa59c5a636..a969b86b950 100644 --- a/apps/desktop/src/app/tools/export/export-desktop.component.html +++ b/apps/desktop/src/app/tools/export/export-desktop.component.html @@ -1,5 +1,5 @@ - {{ "exportVault" | i18n }} + {{ "export" | i18n }} - {{ "exportVault" | i18n }} + {{ "export" | i18n }} diff --git a/apps/web/src/app/tools/import/org-import.component.html b/apps/web/src/app/tools/import/org-import.component.html index 25efa9ec0c7..00e4a7690a2 100644 --- a/apps/web/src/app/tools/import/org-import.component.html +++ b/apps/web/src/app/tools/import/org-import.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} diff --git a/apps/web/src/app/tools/vault-export/export-web.component.html b/apps/web/src/app/tools/vault-export/export-web.component.html index e3d0ca75d25..1ff34f4c988 100644 --- a/apps/web/src/app/tools/vault-export/export-web.component.html +++ b/apps/web/src/app/tools/vault-export/export-web.component.html @@ -15,6 +15,6 @@ bitFormButton buttonType="primary" > - {{ "confirmFormat" | i18n }} + {{ "export" | i18n }} diff --git a/apps/web/src/app/tools/vault-export/org-vault-export.component.html b/apps/web/src/app/tools/vault-export/org-vault-export.component.html index 01975272e76..e781a839896 100644 --- a/apps/web/src/app/tools/vault-export/org-vault-export.component.html +++ b/apps/web/src/app/tools/vault-export/org-vault-export.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "confirmFormat" | i18n }} + {{ "export" | i18n }} diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 6b46cd89956..2ba9dd6fad4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -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(null); - const accounts$ = new BehaviorSubject>({ - [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>([]); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3b0554547c5..0f8b0c1b466 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts index e788ebba7f2..51217a85877 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts @@ -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 { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts index cdb7a5f265a..d7e0cec1840 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -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 { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts index a4dca7378ba..2b9ed6f7bda 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts @@ -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 { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts new file mode 100644 index 00000000000..ae790a67408 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts @@ -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(configuration); + return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.service); + } + case OrganizationIntegrationType.Datadog: { + const datadogConfig = this.convertToJson(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(template); + return this.buildHecTemplate(hecTemplate.index, hecTemplate.service); + } + case OrganizationIntegrationType.Datadog: { + const datadogTemplate = this.convertToJson(template); + return this.buildDataDogTemplate(datadogTemplate.service); + } + default: + throw new Error(`Unsupported integration type: ${type}`); + } + } + + private static convertToJson(jsonString?: string): T { + try { + return JSON.parse(jsonString || "{}") as T; + } catch { + throw new Error("Invalid integration configuration: JSON parse error"); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts index 9aa6e34f478..d8e168aacbe 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts @@ -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 { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts index 7a841697fde..e1b474d0e77 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -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 { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts index 7c51e98282b..fb482d1f367 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts @@ -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; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts index 0209460b630..5271dcd18da 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts @@ -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; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index e9e93adc0ff..9634ad7249a 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -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]; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts index d32c92a460a..84b633a207c 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts @@ -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; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts deleted file mode 100644 index 0545f95cb83..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts +++ /dev/null @@ -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(); - const mockIntegrationConfigurationApiService = - mock(); - 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(); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts deleted file mode 100644 index 1fd5e9f8c06..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts +++ /dev/null @@ -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(null); - private _integrations$ = new BehaviorSubject([]); - private destroy$ = new Subject(); - - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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( - integrationResponse.configuration, - ); - const template = this.convertToJson(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[] = []; - - 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(jsonString?: string): T | null { - try { - return JSON.parse(jsonString || "") as T; - } catch { - return null; - } - } -} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts deleted file mode 100644 index 556078ea862..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts +++ /dev/null @@ -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(); - const mockIntegrationConfigurationApiService = - mock(); - 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(); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts deleted file mode 100644 index b83ea26e166..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts +++ /dev/null @@ -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(null); - private _integrations$ = new BehaviorSubject([]); - private destroy$ = new Subject(); - - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(integrationResponse.configuration); - const template = this.convertToJson(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[] = []; - - 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(jsonString?: string): T | null { - try { - return JSON.parse(jsonString || "") as T; - } catch { - return null; - } - } -} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts index 10ea87486b4..a03b675868d 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts @@ -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; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts new file mode 100644 index 00000000000..767c22e2014 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts @@ -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; + let integrationConfigurationApiService: MockProxy; + + 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(); + integrationConfigurationApiService = mock(); + + 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); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts new file mode 100644 index 00000000000..cd153bc1133 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts @@ -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(null); + private _integrations$ = new BehaviorSubject([]); + + integrations$: Observable = 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 that completes when the operation is done. Subscribe to trigger the load. + */ + setOrganizationId(orgId: OrganizationId): Observable { + 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 { + 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 { + 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 { + 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 { + const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( + switchMap(([responses]) => { + const integrations: OrganizationIntegration[] = []; + const promises: Promise[] = []; + + 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$; + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts index a0a881dbad7..99d54eedc29 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts @@ -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({ diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 3a9159ad68c..95453ffa41a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -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; + }); }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 8beaae7f10a..37bd504643c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -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; const mockI18nService = mock(); const activatedRoute = mock(); - const mockIntegrationService = mock(); - const mockDatadogIntegrationService = mock(); + const mockIntegrationService = mock(); const dialogService = mock(); const toastService = mock(); @@ -54,8 +54,7 @@ describe("IntegrationCardComponent", () => { { provide: I18nPipe, useValue: mock() }, { 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: "", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index e6d4aff05fb..8026e14c2fc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -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, 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, diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts index 2908fe0c089..3560a32fb40 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts @@ -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; const mockActivatedRoute = mock(); - const mockIntegrationService = mock(); - const mockDatadogIntegrationService = mock(); + const mockIntegrationService = mock(); 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(), diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index 58c52e4f40a..a35df3677bb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -1,69 +1,78 @@ - - -
-

{{ "singleSignOn" | i18n }}

-

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

- -
-
+@let organization = organization$ | async; - -
-

- {{ "scimIntegration" | i18n }} -

-

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

- -
-
-

- {{ "bwdc" | i18n }} -

-

{{ "bwdcDesc" | i18n }}

- -
-
+@if (organization) { + + @if (organization?.useSso) { + +
+

{{ "singleSignOn" | i18n }}

+

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

+ +
+
+ } - -
-

- {{ "eventManagement" | i18n }} -

-

{{ "eventManagementDesc" | i18n }}

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

+ {{ "scimIntegration" | i18n }} +

+

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

+ +
+
+

+ {{ "bwdc" | i18n }} +

+

{{ "bwdcDesc" | i18n }}

+ +
+
+ } - -
-

- {{ "deviceManagement" | i18n }} -

-

{{ "deviceManagementDesc" | i18n }}

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

+ {{ "eventManagement" | i18n }} +

+

{{ "eventManagementDesc" | i18n }}

+ +
+
+ } + + +
+

+ {{ "deviceManagement" | i18n }} +

+

{{ "deviceManagementDesc" | i18n }}

+ +
+
+
+} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 894a8e9a25c..6517182b21e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -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(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts index e3c37b4a42b..789ae548521 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts @@ -1,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({ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts index 0e8c46c8864..7a02e3fb04e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts @@ -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; - const hecOrgIntegrationSvc = mock(); - const datadogOrgIntegrationSvc = mock(); + const orgIntegrationSvc = mock(); const activatedRouteMock = { snapshot: { paramMap: { get: jest.fn() } }, @@ -60,8 +58,7 @@ describe("IntegrationsComponent", () => { { provide: ActivatedRoute, useValue: activatedRouteMock }, { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, - { provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc }, - { provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc }, + { provide: OrganizationIntegrationService, useValue: orgIntegrationSvc }, ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationsComponent); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts index 04240da3176..bcfbb9b3f2c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts @@ -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({ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html index 0ea8caef4d6..ac70e1920ee 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -52,12 +52,12 @@ [relativeTo]="route.parent" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts index 056f7cfe255..606cb835ff1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts @@ -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 = mock(); 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(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html index 9e1f2e01591..113c51327b2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html @@ -17,6 +17,6 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index e2b66d9ffa6..5e6f81d99d6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -124,7 +124,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { const ref = openUserVerificationPrompt(this.dialogService, { data: { confirmDescription: "exportSecretsWarningDesc", - confirmButtonText: "exportSecrets", + confirmButtonText: "export", modalTitle: "confirmSecretsExport", }, }); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html index 353d8d8c8ed..3a663dbcbe9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html @@ -36,6 +36,6 @@ {{ "acceptedFormats" | i18n }} Bitwarden (json) diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts index a4f77e6de0b..aa722e31681 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts @@ -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; 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(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts index ddc9964060e..31029d134fa 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts @@ -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", }, }, ]; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts index 37a0dc06837..903bfd35122 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts @@ -31,7 +31,7 @@ import { PeopleAccessPoliciesRequest } from "./models/requests/people-access-pol import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request"; import { ServiceAccountGrantedPoliciesRequest } from "./models/requests/service-account-granted-policies.request"; -import { trackEmissions } from "@bitwarden/common/../spec"; +import { trackEmissions, mockAccountInfoWith } from "@bitwarden/common/../spec"; const SomeCsprngArray = new Uint8Array(64) as CsprngArray; const SomeOrganization = "some organization" as OrganizationId; @@ -52,9 +52,10 @@ describe("AccessPolicyService", () => { let accountService: MockProxy; 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(() => { diff --git a/libs/angular/src/auth/guards/auth.guard.spec.ts b/libs/angular/src/auth/guards/auth.guard.spec.ts index fccfcd58874..335e31ec4d8 100644 --- a/libs/angular/src/auth/guards/auth.guard.spec.ts +++ b/libs/angular/src/auth/guards/auth.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, 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 { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { authGuard } from "./auth.guard"; @@ -38,16 +35,13 @@ describe("AuthGuard", () => { const accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); if (featureFlag) { configService.getFeatureFlag.mockResolvedValue(true); diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index da89ee786b7..af36df06097 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { KeyService } from "@bitwarden/key-management"; @@ -68,16 +65,13 @@ describe("lockGuard", () => { const accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); const testBed = TestBed.configureTestingModule({ imports: [ diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts index 004499beede..6dc91fbb925 100644 --- a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts @@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g import { Account, 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 { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard"; @@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked. describe("redirectToVaultIfUnlockedGuard", () => { const activeUser: Account = { id: "userId" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => { diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts index 4408452a2a2..17df6d1d76b 100644 --- a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts @@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard"; describe("tdeDecryptionRequiredGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/guards/unauth.guard.spec.ts b/libs/angular/src/auth/guards/unauth.guard.spec.ts index c696b849558..284f595f81a 100644 --- a/libs/angular/src/auth/guards/unauth.guard.spec.ts +++ b/libs/angular/src/auth/guards/unauth.guard.spec.ts @@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard"; describe("UnauthGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts index b21264eb7c8..4dc7522c1b8 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts @@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => { validationService = mock(); accountService.activeAccount$ = of({ - email: testEmail, id: "test-user-id" as UserId, - emailVerified: true, - name: null, + ...mockAccountInfoWith({ + email: testEmail, + name: null, + }), }); await TestBed.configureTestingModule({ diff --git a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts index d14e33c1fdc..5dfc5ffa245 100644 --- a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts @@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => { const user: Account = { id: userId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + emailVerified: false, + }), }; const passwordInputResult: PasswordInputResult = { diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts index 76cfbc0bfdd..610ec5923eb 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts @@ -2,14 +2,13 @@ import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { AccountInfo } 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 { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { FakeAccountService } from "@bitwarden/common/spec"; +import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -22,17 +21,15 @@ import { PromptMigrationPasswordComponent } from "./prompt-migration-password.co const SomeUser = "SomeUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId; -const accounts: Record = { - [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("DefaultEncryptedMigrationsSchedulerService", () => { diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index ae375c8b2f5..acb32969f08 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -189,6 +189,7 @@ export abstract class LoginStrategy { name: accountInformation.name, email: accountInformation.email ?? "", emailVerified: accountInformation.email_verified ?? false, + creationDate: undefined, // We don't get a creation date in the token. See https://bitwarden.atlassian.net/browse/PM-29551 for consolidation plans. }); // User env must be seeded from currently set env before switching to the account diff --git a/libs/auth/src/common/services/accounts/lock.services.spec.ts b/libs/auth/src/common/services/accounts/lock.services.spec.ts index e22a6f71581..41e3768d80b 100644 --- a/libs/auth/src/common/services/accounts/lock.services.spec.ts +++ b/libs/auth/src/common/services/accounts/lock.services.spec.ts @@ -8,7 +8,7 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; -import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { mockAccountServiceWith, mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -79,17 +79,21 @@ describe("DefaultLockService", () => { ); it("locks the active account last", async () => { - await accountService.addAccount(mockUser2, { - name: "name2", - email: "email2@example.com", - emailVerified: false, - }); + await accountService.addAccount( + mockUser2, + mockAccountInfoWith({ + name: "name2", + email: "email2@example.com", + }), + ); - await accountService.addAccount(mockUser3, { - name: "name3", - email: "name3@example.com", - emailVerified: false, - }); + await accountService.addAccount( + mockUser3, + mockAccountInfoWith({ + name: "name3", + email: "name3@example.com", + }), + ); const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined); diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 389975dc2e1..ed8b7796966 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs"; import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service"; import { UserId } from "../src/types/guid"; +/** + * Creates a mock AccountInfo object with sensible defaults that can be overridden. + * Use this when you need just an AccountInfo object in tests. + */ +export function mockAccountInfoWith(info: Partial = {}): AccountInfo { + return { + name: "name", + email: "email", + emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", + ...info, + }; +} + export function mockAccountServiceWith( userId: UserId, info: Partial = {}, activity: Record = {}, ): FakeAccountService { - const fullInfo: AccountInfo = { - ...info, - ...{ - name: "name", - email: "email", - emailVerified: true, - }, - }; + const fullInfo = mockAccountInfoWith(info); const fullActivity = { [userId]: new Date(), ...activity }; @@ -104,6 +111,10 @@ export class FakeAccountService implements AccountService { await this.mock.setAccountEmailVerified(userId, emailVerified); } + async setAccountCreationDate(userId: UserId, creationDate: string): Promise { + await this.mock.setAccountCreationDate(userId, creationDate); + } + async switchAccount(userId: UserId): Promise { const next = userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; @@ -127,4 +138,5 @@ const loggedOutInfo: AccountInfo = { name: undefined, email: "", emailVerified: false, + creationDate: undefined, }; diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 8b0280feb01..78822f3ebd5 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -2,14 +2,11 @@ import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; -/** - * Holds information about an account for use in the AccountService - * if more information is added, be sure to update the equality method. - */ export type AccountInfo = { email: string; emailVerified: boolean; name: string | undefined; + creationDate: string | undefined; }; export type Account = { id: UserId } & AccountInfo; @@ -75,6 +72,12 @@ export abstract class AccountService { * @param emailVerified */ abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise; + /** + * updates the `accounts$` observable with the creation date for the account. + * @param userId + * @param creationDate + */ + abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise; /** * updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account. * @param userId diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index 3e3c878eaac..f517b61ffb6 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -6,6 +6,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom } from "rxjs"; +import { mockAccountInfoWith } from "../../../spec/fake-account-service"; import { FakeGlobalState } from "../../../spec/fake-state"; import { FakeGlobalStateProvider, @@ -27,7 +28,7 @@ import { } from "./account.service"; describe("accountInfoEqual", () => { - const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true }; + const accountInfo = mockAccountInfoWith(); it("compares nulls", () => { expect(accountInfoEqual(null, null)).toBe(true); @@ -64,6 +65,23 @@ describe("accountInfoEqual", () => { expect(accountInfoEqual(accountInfo, same)).toBe(true); expect(accountInfoEqual(accountInfo, different)).toBe(false); }); + + it("compares creationDate", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" }; + + expect(accountInfoEqual(accountInfo, same)).toBe(true); + expect(accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares undefined creationDate", () => { + const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined }); + const same = { ...accountWithoutCreationDate }; + const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" }; + + expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true); + expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false); + }); }); describe("accountService", () => { @@ -76,7 +94,10 @@ describe("accountService", () => { let activeAccountIdState: FakeGlobalState; let accountActivityState: FakeGlobalState>; const userId = Utils.newGuid() as UserId; - const userInfo = { email: "email", name: "name", emailVerified: true }; + const userInfo = mockAccountInfoWith({ + email: "email", + name: "name", + }); beforeEach(() => { messagingService = mock(); @@ -253,6 +274,56 @@ describe("accountService", () => { }); }); + describe("setCreationDate", () => { + const initialState = { [userId]: userInfo }; + beforeEach(() => { + accountsState.stateSubject.next(initialState); + }); + + it("should update the account with a new creation date", async () => { + const newCreationDate = "2024-12-31T00:00:00.000Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...userInfo, creationDate: newCreationDate }, + }); + }); + + it("should not update if the creation date is the same", async () => { + await sut.setAccountCreationDate(userId, userInfo.creationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual(initialState); + }); + + it("should update from undefined to a defined creation date", async () => { + const accountWithoutCreationDate = mockAccountInfoWith({ + ...userInfo, + creationDate: undefined, + }); + accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate }); + + const newCreationDate = "2024-06-15T12:30:00.000Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate }, + }); + }); + + it("should update to a different creation date string format", async () => { + const newCreationDate = "2023-03-15T08:45:30.123Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...userInfo, creationDate: newCreationDate }, + }); + }); + }); + describe("setAccountVerifyNewDeviceLogin", () => { const initialState = true; beforeEach(() => { @@ -294,6 +365,7 @@ describe("accountService", () => { email: "", emailVerified: false, name: undefined, + creationDate: undefined, }, }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index fb4b590ce77..1b028d1eba9 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -62,6 +62,7 @@ const LOGGED_OUT_INFO: AccountInfo = { email: "", emailVerified: false, name: undefined, + creationDate: undefined, }; /** @@ -167,6 +168,10 @@ export class AccountServiceImplementation implements InternalAccountService { await this.setAccountInfo(userId, { emailVerified }); } + async setAccountCreationDate(userId: UserId, creationDate: string): Promise { + await this.setAccountInfo(userId, { creationDate }); + } + async clean(userId: UserId) { await this.setAccountInfo(userId, LOGGED_OUT_INFO); await this.removeAccountActivity(userId); diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts index 0b12e1cb661..a44dde04f5f 100644 --- a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts +++ b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts @@ -15,6 +15,7 @@ import { SystemNotificationEvent, SystemNotificationsService, } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/user-core"; import { AuthRequestAnsweringService } from "./auth-request-answering.service"; @@ -48,14 +49,16 @@ describe("AuthRequestAnsweringService", () => { // Common defaults authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); - accountService.activeAccount$ = of({ - id: userId, + const accountInfo = mockAccountInfoWith({ email: "user@example.com", - emailVerified: true, name: "User", }); + accountService.activeAccount$ = of({ + id: userId, + ...accountInfo, + }); accountService.accounts$ = of({ - [userId]: { email: "user@example.com", emailVerified: true, name: "User" }, + [userId]: accountInfo, }); (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( of(ForceSetPasswordReason.None), diff --git a/libs/common/src/auth/services/auth.service.spec.ts b/libs/common/src/auth/services/auth.service.spec.ts index 5dcb8c372e5..c7ff55e6bb1 100644 --- a/libs/common/src/auth/services/auth.service.spec.ts +++ b/libs/common/src/auth/services/auth.service.spec.ts @@ -10,6 +10,7 @@ import { makeStaticByteArray, mockAccountServiceWith, trackEmissions, + mockAccountInfoWith, } from "../../../spec"; import { ApiService } from "../../abstractions/api.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; @@ -58,9 +59,10 @@ describe("AuthService", () => { const accountInfo = { status: AuthenticationStatus.Unlocked, id: userId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + }), }; beforeEach(() => { @@ -112,9 +114,10 @@ describe("AuthService", () => { const accountInfo2 = { status: AuthenticationStatus.Unlocked, id: Utils.newGuid() as UserId, - email: "email2", - emailVerified: false, - name: "name2", + ...mockAccountInfoWith({ + email: "email2", + name: "name2", + }), }; const emissions = trackEmissions(sut.activeAccountStatus$); @@ -131,11 +134,13 @@ describe("AuthService", () => { it("requests auth status for all known users", async () => { const userId2 = Utils.newGuid() as UserId; - await accountService.addAccount(userId2, { - email: "email2", - emailVerified: false, - name: "name2", - }); + await accountService.addAccount( + userId2, + mockAccountInfoWith({ + email: "email2", + name: "name2", + }), + ); const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked)); sut.authStatusFor$ = mockFn; diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts index 7e6e0d53f57..693992d4c4a 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; +import { mockAccountInfoWith } from "../../../spec/fake-account-service"; import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { UserId } from "../../types/guid"; -import { Account, AccountInfo, AccountService } from "../abstractions/account.service"; +import { Account, AccountService } from "../abstractions/account.service"; import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation"; @@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { const encryptedKey = { encryptedString: "encryptedString" }; organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any); - const user1AccountInfo: AccountInfo = { + const user1AccountInfo = mockAccountInfoWith({ name: "Test User 1", email: "test1@email.com", - emailVerified: true, - }; + }); activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); keyService.userKey$.mockReturnValue(of({ key: "key" } as any)); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index c02bc85f124..ce272705341 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction { // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout // but we can simply clear all locations to avoid the need to require those parameters. + // When secure storage is supported, clear the encryption key from secure storage. + // When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped. if (this.platformSupportsSecureStorage) { - // Always clear the access token key when clearing the access token - // The next set of the access token will create a new access token key + // Always clear the access token key when clearing the access token. + // The next set of the access token will create a new access token key. await this.clearAccessTokenKey(userId); } - // Platform doesn't support secure storage, so use state provider implementation + // Clear tokens from disk storage (all platforms) await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, { shouldUpdate: (previousValue) => previousValue !== null, }); @@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction { return null; } + // When platformSupportsSecureStorage=true, tokens on disk are encrypted and require + // decryption keys from secure storage. When false (e.g., portable builds), tokens are + // stored on disk. if (this.platformSupportsSecureStorage) { let accessTokenKey: AccessTokenKey; try { @@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction { ) { return TokenStorageLocation.Memory; } else { + // Secure storage (e.g., OS credential manager) is preferred when available. + // Desktop portable builds set platformSupportsSecureStorage=false to store tokens + // on disk for portability across machines. if (useSecureStorage && this.platformSupportsSecureStorage) { return TokenStorageLocation.SecureStorage; } diff --git a/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts b/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts new file mode 100644 index 00000000000..6c915c2dcbe --- /dev/null +++ b/libs/common/src/dirt/services/abstractions/phishing-detection-settings.service.abstraction.ts @@ -0,0 +1,37 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +/** + * Abstraction for phishing detection settings + */ +export abstract class PhishingDetectionSettingsServiceAbstraction { + /** + * An observable for whether phishing detection is available for the active user account. + * + * Access is granted only when the PhishingDetection feature flag is enabled and + * at least one of the following is true for the active account: + * - the user has a personal premium subscription + * - the user is a member of a Family org (ProductTierType.Families) + * - the user is a member of an Enterprise org with `usePhishingBlocker` enabled + * + * Note: Non-specified organization types (e.g., Team orgs) do not grant access. + */ + abstract readonly available$: Observable; + /** + * An observable for whether phishing detection is on for the active user account + * + * This is true when {@link available$} is true and when {@link enabled$} is true + */ + abstract readonly on$: Observable; + /** + * An observable for whether phishing detection is enabled + */ + abstract readonly enabled$: Observable; + /** + * Sets whether phishing detection is enabled + * + * @param enabled True to enable, false to disable + */ + abstract setEnabled: (userId: UserId, enabled: boolean) => Promise; +} diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts new file mode 100644 index 00000000000..23e311d9445 --- /dev/null +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts @@ -0,0 +1,203 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, Subject } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { UserId } from "../../../types/guid"; + +import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service"; + +describe("PhishingDetectionSettingsService", () => { + // Mock services + let mockAccountService: MockProxy; + let mockBillingService: MockProxy; + let mockConfigService: MockProxy; + let mockOrganizationService: MockProxy; + + // RxJS Subjects we control in the tests + let activeAccountSubject: BehaviorSubject; + let featureFlagSubject: BehaviorSubject; + let premiumStatusSubject: BehaviorSubject; + let organizationsSubject: BehaviorSubject; + + let service: PhishingDetectionSettingsService; + let stateProvider: FakeStateProvider; + + // Constant mock data + const familyOrg = mock({ + canAccess: true, + isMember: true, + usersGetPremium: true, + productTierType: ProductTierType.Families, + usePhishingBlocker: true, + }); + const teamOrg = mock({ + canAccess: true, + isMember: true, + usersGetPremium: true, + productTierType: ProductTierType.Teams, + usePhishingBlocker: true, + }); + const enterpriseOrg = mock({ + canAccess: true, + isMember: true, + usersGetPremium: true, + productTierType: ProductTierType.Enterprise, + usePhishingBlocker: true, + }); + + const mockUserId = "mock-user-id" as UserId; + const account = mock({ id: mockUserId }); + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + + beforeEach(() => { + // Initialize subjects + activeAccountSubject = new BehaviorSubject(null); + featureFlagSubject = new BehaviorSubject(false); + premiumStatusSubject = new BehaviorSubject(false); + organizationsSubject = new BehaviorSubject([]); + + // Default implementations for required functions + mockAccountService = mock(); + mockAccountService.activeAccount$ = activeAccountSubject.asObservable(); + + mockBillingService = mock(); + mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable()); + + mockConfigService = mock(); + mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable()); + + mockOrganizationService = mock(); + mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable()); + + stateProvider = new FakeStateProvider(accountService); + service = new PhishingDetectionSettingsService( + mockAccountService, + mockBillingService, + mockConfigService, + mockOrganizationService, + stateProvider, + ); + }); + + // Helper to easily get the result of the observable we are testing + const getAccess = () => firstValueFrom(service.available$); + + describe("enabled$", () => { + it("should default to true if an account is logged in", async () => { + activeAccountSubject.next(account); + const result = await firstValueFrom(service.enabled$); + expect(result).toBe(true); + }); + + it("should return the stored value", async () => { + activeAccountSubject.next(account); + + await service.setEnabled(mockUserId, false); + const resultDisabled = await firstValueFrom(service.enabled$); + expect(resultDisabled).toBe(false); + + await service.setEnabled(mockUserId, true); + const resultEnabled = await firstValueFrom(service.enabled$); + expect(resultEnabled).toBe(true); + }); + }); + + describe("setEnabled", () => { + it("should update the stored value", async () => { + activeAccountSubject.next(account); + await service.setEnabled(mockUserId, false); + let result = await firstValueFrom(service.enabled$); + expect(result).toBe(false); + + await service.setEnabled(mockUserId, true); + result = await firstValueFrom(service.enabled$); + expect(result).toBe(true); + }); + }); + + it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => { + activeAccountSubject.next(account); + premiumStatusSubject.next(true); + organizationsSubject.next([familyOrg]); + + featureFlagSubject.next(false); + + await expect(getAccess()).resolves.toBe(false); + }); + + it("returns false if there is no active account present yet", async () => { + activeAccountSubject.next(null); // No active account + featureFlagSubject.next(true); // Flag is on + + await expect(getAccess()).resolves.toBe(false); + }); + + it("returns true when feature flag is enabled and user has premium personally", async () => { + activeAccountSubject.next(account); + featureFlagSubject.next(true); + organizationsSubject.next([]); + premiumStatusSubject.next(true); + + await expect(getAccess()).resolves.toBe(true); + }); + + it("returns true when feature flag is enabled and user is in a Family Organization", async () => { + activeAccountSubject.next(account); + featureFlagSubject.next(true); + premiumStatusSubject.next(false); // User has no personal premium + + organizationsSubject.next([familyOrg]); + + await expect(getAccess()).resolves.toBe(true); + }); + + it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => { + activeAccountSubject.next(account); + featureFlagSubject.next(true); + premiumStatusSubject.next(false); + organizationsSubject.next([enterpriseOrg]); + + await expect(getAccess()).resolves.toBe(true); + }); + + it("returns false when user has no access through personal premium or organizations", async () => { + activeAccountSubject.next(account); + featureFlagSubject.next(true); + premiumStatusSubject.next(false); + organizationsSubject.next([teamOrg]); // Team org does not give access + + await expect(getAccess()).resolves.toBe(false); + }); + + it("shares/caches the available$ result between multiple subscribers", async () => { + // Use a plain Subject for this test so we control when the premium observable emits + // and avoid the BehaviorSubject's initial emission which can race with subscriptions. + // Provide the Subject directly as the mock return value for the billing service + const oneTimePremium = new Subject(); + mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable()); + + activeAccountSubject.next(account); + featureFlagSubject.next(true); + organizationsSubject.next([]); + + const p1 = firstValueFrom(service.available$); + const p2 = firstValueFrom(service.available$); + + // Trigger the pipeline + oneTimePremium.next(true); + + const [first, second] = await Promise.all([p1, p2]); + + expect(first).toBe(true); + expect(second).toBe(true); + // The billing function should have been called at most once due to caching + expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts new file mode 100644 index 00000000000..36d50f60de7 --- /dev/null +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts @@ -0,0 +1,116 @@ +import { combineLatest, Observable, of, switchMap } from "rxjs"; +import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { UserId } from "@bitwarden/user-core"; + +import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; +import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction"; + +const ENABLE_PHISHING_DETECTION = new UserKeyDefinition( + PHISHING_DETECTION_DISK, + "enablePhishingDetection", + { + deserializer: (value: boolean) => value ?? true, // Default: enabled + clearOn: [], + }, +); + +export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction { + readonly available$: Observable; + readonly enabled$: Observable; + readonly on$: Observable; + + constructor( + private accountService: AccountService, + private billingService: BillingAccountProfileStateService, + private configService: ConfigService, + private organizationService: OrganizationService, + private stateProvider: StateProvider, + ) { + this.available$ = this.buildAvailablePipeline$().pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + this.enabled$ = this.buildEnabledPipeline$().pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.on$ = combineLatest([this.available$, this.enabled$]).pipe( + map(([available, enabled]) => available && enabled), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + async setEnabled(userId: UserId, enabled: boolean): Promise { + await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled); + } + + /** + * Builds the observable pipeline to determine if phishing detection is available to the user + * + * @returns An observable pipeline that determines if phishing detection is available + */ + private buildAvailablePipeline$(): Observable { + return combineLatest([ + this.accountService.activeAccount$, + this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection), + ]).pipe( + switchMap(([account, featureEnabled]) => { + if (!account || !featureEnabled) { + return of(false); + } + return combineLatest([ + this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))), + this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))), + ]).pipe( + map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)), + catchError(() => of(false)), + ); + }), + ); + } + + /** + * Builds the observable pipeline to determine if phishing detection is enabled by the user + * + * @returns True if phishing detection is enabled for the active user + */ + private buildEnabledPipeline$(): Observable { + return this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (!account) { + return of(false); + } + return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id); + }), + map((enabled) => enabled ?? true), + ); + } + + /** + * Determines if any of the user's organizations grant access to phishing detection + * + * @param organizations The organizations the user is a member of + * @returns True if any organization grants access to phishing detection + */ + private orgGrantsAccess(organizations: Organization[]): boolean { + return organizations.some((org) => { + if (!org.canAccess || !org.isMember || !org.usersGetPremium) { + return false; + } + return ( + org.productTierType === ProductTierType.Families || + (org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker) + ); + }); + } +} diff --git a/libs/common/src/key-management/vault-timeout/index.ts b/libs/common/src/key-management/vault-timeout/index.ts index ba32c12c9fb..1879de5353c 100644 --- a/libs/common/src/key-management/vault-timeout/index.ts +++ b/libs/common/src/key-management/vault-timeout/index.ts @@ -10,3 +10,5 @@ export { VaultTimeoutNumberType, VaultTimeoutStringType, } from "./types/vault-timeout.type"; +// Only used by desktop's electron-key.service.spec.ts test +export { VAULT_TIMEOUT } from "./services/vault-timeout-settings.state"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index dc0c5620518..b8bc859d11c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -13,6 +13,7 @@ import { shareReplay, switchMap, tap, + concatMap, } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -150,7 +151,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return from( this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData), ).pipe( - tap((vaultTimeout: VaultTimeout) => { + concatMap(async (vaultTimeout: VaultTimeout) => { this.logService.debug( "[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s", vaultTimeout, @@ -159,8 +160,9 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current if (vaultTimeout !== currentVaultTimeout) { - return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId); + await this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId); } + return vaultTimeout; }), catchError((error: unknown) => { // Protect outer observable from canceling on error by catching and returning EMPTY diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index 51eec18f173..8f7f93f368c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, from, of } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { LockService, LogoutService } from "@bitwarden/auth/common"; -import { FakeAccountService, mockAccountServiceWith } from "../../../../spec"; +import { FakeAccountService, mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec"; import { AccountInfo } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -109,19 +109,19 @@ describe("VaultTimeoutService", () => { if (globalSetups?.userId) { accountService.activeAccountSubject.next({ id: globalSetups.userId as UserId, - email: null, - emailVerified: false, - name: null, + ...mockAccountInfoWith({ + email: null, + name: null, + }), }); } accountService.accounts$ = of( Object.entries(accounts).reduce( (agg, [id]) => { - agg[id] = { + agg[id] = mockAccountInfoWith({ email: "", - emailVerified: true, name: "", - }; + }); return agg; }, {} as Record, diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index cd1bf97150c..46178f62a07 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -7,6 +7,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { mockAccountInfoWith } from "../../../../spec"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -163,9 +164,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => { } else { activeUserAccount$.next({ id: userId, - email: "email", - name: "Test Name", - emailVerified: true, + ...mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), }); } } @@ -174,7 +176,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => { const currentAccounts = (userAccounts$.getValue() as Record) ?? {}; userAccounts$.next({ ...currentAccounts, - [userId]: { email: "email", name: "Test Name", emailVerified: true }, + [userId]: mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), } as any); } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index 4a9b0809ac9..9c84981b7f9 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -8,7 +8,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -import { awaitAsync } from "../../../../spec"; +import { awaitAsync, mockAccountInfoWith } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -139,11 +139,18 @@ describe("NotificationsService", () => { activeAccount.next(null); accounts.next({} as any); } else { - activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true }); + const accountInfo = mockAccountInfoWith({ + email: "email", + name: "Test Name", + }); + activeAccount.next({ + id: userId, + ...accountInfo, + }); const current = (accounts.getValue() as Record) ?? {}; accounts.next({ ...current, - [userId]: { email: "email", name: "Test Name", emailVerified: true }, + [userId]: accountInfo, } as any); } } @@ -349,7 +356,13 @@ describe("NotificationsService", () => { describe("processNotification", () => { beforeEach(async () => { appIdService.getAppId.mockResolvedValue("test-app-id"); - activeAccount.next({ id: mockUser1, email: "email", name: "Test Name", emailVerified: true }); + activeAccount.next({ + id: mockUser1, + ...mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), + }); }); describe("NotificationType.LogOut", () => { diff --git a/libs/common/src/platform/services/default-environment.service.spec.ts b/libs/common/src/platform/services/default-environment.service.spec.ts index 553f80f83b8..9e8a41616a3 100644 --- a/libs/common/src/platform/services/default-environment.service.spec.ts +++ b/libs/common/src/platform/services/default-environment.service.spec.ts @@ -1,6 +1,6 @@ import { firstValueFrom } from "rxjs"; -import { FakeStateProvider, awaitAsync } from "../../../spec"; +import { FakeStateProvider, awaitAsync, mockAccountInfoWith } from "../../../spec"; import { FakeAccountService } from "../../../spec/fake-account-service"; import { UserId } from "../../types/guid"; import { CloudRegion, Region } from "../abstractions/environment.service"; @@ -28,16 +28,14 @@ describe("EnvironmentService", () => { beforeEach(async () => { accountService = new FakeAccountService({ - [testUser]: { + [testUser]: mockAccountInfoWith({ name: "name", email: "email", - emailVerified: false, - }, - [alternateTestUser]: { + }), + [alternateTestUser]: mockAccountInfoWith({ name: "name", email: "email", - emailVerified: false, - }, + }), }); stateProvider = new FakeStateProvider(accountService); @@ -47,9 +45,10 @@ describe("EnvironmentService", () => { const switchUser = async (userId: UserId) => { accountService.activeAccountSubject.next({ id: userId, - email: "test@example.com", - name: `Test Name ${userId}`, - emailVerified: false, + ...mockAccountInfoWith({ + email: "test@example.com", + name: `Test Name ${userId}`, + }), }); await awaitAsync(); }; diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index fef64399b40..9c50bd1ab65 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -3,7 +3,7 @@ import { TextEncoder } from "util"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; -import { mockAccountServiceWith } from "../../../../spec"; +import { mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec"; import { Account } from "../../../auth/abstractions/account.service"; import { CipherId, UserId } from "../../../types/guid"; import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service"; @@ -40,9 +40,10 @@ describe("FidoAuthenticatorService", () => { const userId = "testId" as UserId; const activeAccountSubject = new BehaviorSubject({ id: userId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); let cipherService!: MockProxy; diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 1286ea7b7f9..fb9c1fae77e 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -12,9 +12,9 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; -import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; @@ -92,7 +92,10 @@ describe("DefaultSdkService", () => { .calledWith(userId) .mockReturnValue(new BehaviorSubject(mock())); accountService.accounts$ = of({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); kdfConfigService.getKdfConfig$ .calledWith(userId) diff --git a/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts index 0a05ac8dbf4..1f4d086f729 100644 --- a/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts @@ -8,9 +8,9 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; -import { AccountInfo } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -76,7 +76,10 @@ describe("DefaultRegisterSdkService", () => { .calledWith(userId) .mockReturnValue(new BehaviorSubject(mock())); accountService.accounts$ = of({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); }); @@ -125,7 +128,10 @@ describe("DefaultRegisterSdkService", () => { it("destroys the internal SDK client when the account is removed (logout)", async () => { const accounts$ = new BehaviorSubject({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); accountService.accounts$ = accounts$; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 910702bddd0..8d2ccaffa18 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -272,6 +272,7 @@ export class DefaultSyncService extends CoreSyncService { await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices); + await this.accountService.setAccountCreationDate(response.id, response.creationDate); await this.billingAccountProfileStateService.setHasPremium( response.premiumPersonally, diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index 1fb8f86697f..9ab84ecb16b 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -6,6 +6,7 @@ import { ObservedValueOf, of } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/user-core"; +import { mockAccountInfoWith } from "../../spec"; import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceType } from "../enums"; @@ -55,9 +56,10 @@ describe("ApiService", () => { accountService.activeAccount$ = of({ id: testActiveUser, - email: "user1@example.com", - emailVerified: true, - name: "Test Name", + ...mockAccountInfoWith({ + email: "user1@example.com", + name: "Test Name", + }), } satisfies ObservedValueOf); httpOperations = mock(); diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts index 9959488feca..c0dec8728fe 100644 --- a/libs/common/src/tools/extension/extension.service.spec.ts +++ b/libs/common/src/tools/extension/extension.service.spec.ts @@ -1,7 +1,12 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec"; +import { + FakeAccountService, + FakeStateProvider, + awaitAsync, + mockAccountInfoWith, +} from "../../../spec"; import { Account } from "../../auth/abstractions/account.service"; import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; @@ -21,9 +26,10 @@ import { SimpleLogin } from "./vendor/simplelogin"; const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; const SomeAccount$ = new BehaviorSubject(SomeAccount); diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 96fb2f43c88..397ae905e31 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -11,6 +11,7 @@ import { FakeStateProvider, awaitAsync, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -71,9 +72,10 @@ describe("SendService", () => { accountService.activeAccountSubject.next({ id: mockUserId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + }), }); // Initial encrypted state diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index a6d452d37fd..b88c358b6ab 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -6,6 +6,7 @@ import { awaitAsync, FakeAccountService, FakeStateProvider, + mockAccountInfoWith, ObservableTracker, } from "../../../spec"; import { Account } from "../../auth/abstractions/account.service"; @@ -23,17 +24,19 @@ import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; const SomeAccount$ = new BehaviorSubject(SomeAccount); const SomeOtherAccount = { id: "some other user" as UserId, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; type TestType = { foo: string }; diff --git a/libs/common/src/vault/icon/build-cipher-icon.spec.ts b/libs/common/src/vault/icon/build-cipher-icon.spec.ts index 90ccaaec3a6..67a1151aa8e 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.spec.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.spec.ts @@ -13,18 +13,19 @@ describe("buildCipherIcon", () => { }, } as any as CipherView; - it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => { - setUri("androidapp://test.example"); + // @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028 + // it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => { + // setUri("androidapp://test.example"); - const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); + // const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); - expect(iconDetails).toEqual({ - icon: "bwi-android", - image: null, - fallbackImage: "", - imageEnabled: showFavicon, - }); - }); + // expect(iconDetails).toEqual({ + // icon: "bwi-android", + // image: null, + // fallbackImage: "", + // imageEnabled: showFavicon, + // }); + // }); it("does not mark as an android app if the protocol is not androidapp", () => { // This weird URI points to test.androidapp with a default port and path of /.example @@ -40,18 +41,18 @@ describe("buildCipherIcon", () => { }); }); - it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => { - setUri("iosapp://test.example"); + // @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028 + // it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => { + // setUri("iosapp://test.example"); - const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); - - expect(iconDetails).toEqual({ - icon: "bwi-apple", - image: null, - fallbackImage: "", - imageEnabled: showFavicon, - }); - }); + // const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); + // expect(iconDetails).toEqual({ + // icon: "bwi-apple", + // image: null, + // fallbackImage: "", + // imageEnabled: showFavicon, + // }); + // }); it("does not mark as an ios app if the protocol is not iosapp", () => { // This weird URI points to test.iosapp with a default port and path of /.example diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index a081511d792..77787874d8e 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -49,10 +49,12 @@ export function buildCipherIcon( let isWebsite = false; if (hostnameUri.indexOf("androidapp://") === 0) { - icon = "bwi-android"; + // @TODO Re-add once we have Android icon https://bitwarden.atlassian.net/browse/PM-29028 + // icon = "bwi-android"; image = null; } else if (hostnameUri.indexOf("iosapp://") === 0) { - icon = "bwi-apple"; + // @TODO Re-add once we have iOS icon https://bitwarden.atlassian.net/browse/PM-29028 + // icon = "bwi-apple"; image = null; } else if ( showFavicon && diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 73a3d34261b..1079329448b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -7,6 +7,7 @@ [hideCardWrapper]="hideCardWrapper" [hideBackgroundIllustration]="hideBackgroundIllustration" > + diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts index 76fcc8976c7..63181e04649 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts @@ -130,6 +130,15 @@ export class DefaultSecondaryOutletExampleComponent {} }) export class DefaultEnvSelectorOutletExampleComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-header-actions-outlet-example-component", + template: "

Header Actions Outlet Example:
your header actions component goes here

", + standalone: false, +}) +export class DefaultHeaderActionsOutletExampleComponent {} + export const DefaultContentExample: Story = { render: (args) => ({ props: args, @@ -171,6 +180,11 @@ export const DefaultContentExample: Story = { component: DefaultEnvSelectorOutletExampleComponent, outlet: "environment-selector", }, + { + path: "", + component: DefaultHeaderActionsOutletExampleComponent, + outlet: "header-actions", + }, ], }, ], diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 15f7d107542..edb73bbf588 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -5,13 +5,19 @@ 'tw-min-h-full': clientType === 'browser' || clientType === 'desktop', }" > - - - +
+ @if (!hideLogo()) { + + + + } +
+ +
+
@let iconInput = icon(); @@ -25,7 +31,7 @@
- + @if (title()) {

{{ title() }} @@ -34,9 +40,11 @@

{{ title() }}

-
+ } -
{{ subtitle() }}
+ @if (subtitle()) { +
{{ subtitle() }}
+ }
-
-
- {{ "accessing" | i18n }} {{ hostname }} -
- - - - -
© {{ year }} Bitwarden Inc.
-
{{ version }}
-
-
+ @if (!hideFooter()) { +
+ @if (showReadonlyHostname()) { +
{{ "accessing" | i18n }} {{ hostname }}
+ } @else { + + } + + @if (!hideYearAndVersion) { +
© {{ year }} Bitwarden Inc.
+
{{ version }}
+ } +
+ } @if (!hideBackgroundIllustration()) {
+ @if (includeHeaderActions) { +
+ +
+ }
Thin Content
@@ -116,7 +126,7 @@ export default { hideLogo: { control: "boolean" }, hideFooter: { control: "boolean" }, hideBackgroundIllustration: { control: "boolean" }, - + includeHeaderActions: { control: "boolean" }, contentLength: { control: "radio", options: ["normal", "long", "thin"], @@ -138,6 +148,7 @@ export default { hideBackgroundIllustration: false, contentLength: "normal", showSecondary: false, + includeHeaderActions: false, }, } satisfies Meta; @@ -188,6 +199,12 @@ export const SecondaryContent: Story = { }, }; +export const WithHeaderActions: Story = { + args: { + includeHeaderActions: true, + }, +}; + export const NoTitle: Story = { args: { title: undefined } }; export const NoSubtitle: Story = { args: { subtitle: undefined } }; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 2ba85e32772..f50807dd506 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,5 +1,5 @@ import { NgClass } from "@angular/common"; -import { Component, computed, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -14,13 +14,11 @@ const SizeClasses: Record = { }; /** - * Avatars display a unique color that helps a user visually recognize their logged in account. - - * A variance in color across the avatar component is important as it is used in Account Switching as a - * visual indicator to recognize which of a personal or work account a user is logged into. -*/ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection + * Avatars display a unique color that helps a user visually recognize their logged in account. + * + * A variance in color across the avatar component is important as it is used in Account Switching as a + * visual indicator to recognize which of a personal or work account a user is logged into. + */ @Component({ selector: "bit-avatar", template: ` @@ -49,13 +47,38 @@ const SizeClasses: Record = { `, imports: [NgClass], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AvatarComponent { + /** + * Whether to display a border around the avatar. + */ readonly border = input(false); + + /** + * Custom background color for the avatar. If not provided, a color will be generated based on the id or text. + */ readonly color = input(); + + /** + * Unique identifier used to generate a consistent background color. Takes precedence over text for color generation. + */ readonly id = input(); + + /** + * Text to display in the avatar. The first letters of words (up to 2 characters) will be shown. + * Also used to generate background color if id is not provided. + */ readonly text = input(); + + /** + * Title attribute for the avatar. If not provided, falls back to the text value. + */ readonly title = input(); + + /** + * Size of the avatar. + */ readonly size = input("default"); protected readonly svgCharCount = 2; diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index a4af25a2492..1ead9f82273 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -83,7 +83,7 @@
- {{ "importData" | i18n }} + {{ "import" | i18n }}