diff --git a/apps/web/src/images/integrations/logo-huntress-siem-darkmode.svg b/apps/web/src/images/integrations/logo-huntress-siem-darkmode.svg new file mode 100644 index 00000000000..c25edc826e9 --- /dev/null +++ b/apps/web/src/images/integrations/logo-huntress-siem-darkmode.svg @@ -0,0 +1,63 @@ + + + + + + + diff --git a/apps/web/src/images/integrations/logo-huntress-siem.svg b/apps/web/src/images/integrations/logo-huntress-siem.svg index 06f2a3443c0..99c63a850f8 100644 --- a/apps/web/src/images/integrations/logo-huntress-siem.svg +++ b/apps/web/src/images/integrations/logo-huntress-siem.svg @@ -1 +1,72 @@ - \ No newline at end of file + + + diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7ea2abb5d08..9c7bcfa25b0 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10624,6 +10624,15 @@ "huntressEventIntegrationDesc": { "message": "Send event data to your Huntress SIEM instance" }, + "integrationConnectedSuccessfully":{ + "message": "$INTEGRATION$ connected successfully.", + "placeholders": { + "integration": { + "content": "$1", + "example": "Crowdstrike" + } + } + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, 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 index 767c22e2014..8c4bcbec24e 100644 --- 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 @@ -9,6 +9,7 @@ import { } from "@bitwarden/common/types/guid"; import { OrgIntegrationBuilder } from "../models/integration-builder"; +import { OrganizationIntegration } from "../models/organization-integration"; import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; @@ -207,7 +208,11 @@ describe("OrganizationIntegrationService", () => { const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template); - expect(result).toEqual({ mustBeOwner: false, success: true }); + expect(result).toEqual({ + mustBeOwner: false, + success: true, + organizationIntegrationResult: expect.any(OrganizationIntegration), + }); expect(integrationApiService.createOrganizationIntegration).toHaveBeenCalledWith( orgId, expect.any(OrganizationIntegrationRequest), @@ -325,7 +330,11 @@ describe("OrganizationIntegrationService", () => { template, ); - expect(result).toEqual({ mustBeOwner: false, success: true }); + expect(result).toEqual({ + mustBeOwner: false, + success: true, + organizationIntegrationResult: expect.any(OrganizationIntegration), + }); expect(integrationApiService.updateOrganizationIntegration).toHaveBeenCalledWith( orgId, integrationId, @@ -375,7 +384,11 @@ describe("OrganizationIntegrationService", () => { template, ); - expect(result).toEqual({ mustBeOwner: true, success: false }); + expect(result).toEqual({ + mustBeOwner: true, + success: false, + organizationIntegrationResult: undefined, + }); }); it("should rethrow non-404 errors", async () => { 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 index c9457f4bcfc..355230d07f7 100644 --- 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 @@ -29,6 +29,7 @@ import { OrganizationIntegrationConfigurationApiService } from "./organization-i export type IntegrationModificationResult = { mustBeOwner: boolean; success: boolean; + organizationIntegrationResult?: OrganizationIntegration | undefined; }; /** @@ -113,10 +114,14 @@ export class OrganizationIntegrationService { if (newIntegration !== null) { this._integrations$.next([...this._integrations$.getValue(), newIntegration]); } - return { mustBeOwner: false, success: true }; + return { + mustBeOwner: false, + success: newIntegration !== null, + organizationIntegrationResult: newIntegration ?? undefined, + }; } catch (error) { if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; + return { mustBeOwner: true, success: false, organizationIntegrationResult: undefined }; } throw error; } @@ -178,10 +183,14 @@ export class OrganizationIntegrationService { } this._integrations$.next([...integrations]); } - return { mustBeOwner: false, success: true }; + return { + mustBeOwner: false, + success: updatedIntegration !== null, + organizationIntegrationResult: updatedIntegration ?? undefined, + }; } catch (error) { if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; + return { mustBeOwner: true, success: false, organizationIntegrationResult: undefined }; } throw error; } @@ -221,10 +230,10 @@ export class OrganizationIntegrationService { .filter((i) => i.id !== integrationId); this._integrations$.next(updatedIntegrations); - return { mustBeOwner: false, success: true }; + return { mustBeOwner: false, success: true, organizationIntegrationResult: undefined }; } catch (error) { if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; + return { mustBeOwner: true, success: false, organizationIntegrationResult: undefined }; } throw error; } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/shared/filter-integrations.pipe.ts similarity index 81% rename from bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/shared/filter-integrations.pipe.ts index 10ee251a921..55eccd8cfca 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/shared/filter-integrations.pipe.ts @@ -1,10 +1,12 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { IntegrationType } from "@bitwarden/common/enums"; +import { Integration } from "../models/integration"; + @Pipe({ name: "filterIntegrations", + standalone: true, }) export class FilterIntegrationsPipe implements PipeTransform { transform(integrations: Integration[] | null | undefined, type: IntegrationType): Integration[] { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/shared/integration-state.service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/shared/integration-state.service.ts new file mode 100644 index 00000000000..eb94ef66f1a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/shared/integration-state.service.ts @@ -0,0 +1,18 @@ +import { Signal } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +import { Integration } from "../models/integration"; +import { OrganizationIntegration } from "../models/organization-integration"; + +export abstract class IntegrationStateService { + abstract integrations: Signal; + abstract organization: Signal; + abstract setIntegrations(integrations: Integration[]): void; + abstract setOrganization(organization: Organization | undefined): void; + abstract updateIntegrationSettings( + integrationName: string, + updatedIntegrationSettings: OrganizationIntegration, + ): void; + abstract deleteIntegrationSettings(integrationName: string): void; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts index 18e6dc7e362..4a1718d4a19 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts @@ -1,11 +1,11 @@ import { Component } from "@angular/core"; +import { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "../integrations.pipe"; -import { OrganizationIntegrationsState } from "../organization-integrations.state"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -17,7 +17,7 @@ import { OrganizationIntegrationsState } from "../organization-integrations.stat export class DeviceManagementComponent { integrations = this.state.integrations; - constructor(private state: OrganizationIntegrationsState) {} + constructor(private state: IntegrationStateService) {} get IntegrationType(): typeof IntegrationType { return IntegrationType; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts index 70b17cabd35..0e8296d5a29 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts @@ -1,11 +1,11 @@ import { Component } from "@angular/core"; +import { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "../integrations.pipe"; -import { OrganizationIntegrationsState } from "../organization-integrations.state"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -16,7 +16,8 @@ import { OrganizationIntegrationsState } from "../organization-integrations.stat }) export class EventManagementComponent { integrations = this.state.integrations; - constructor(private state: OrganizationIntegrationsState) {} + + constructor(private state: IntegrationStateService) {} get IntegrationType(): typeof IntegrationType { return IntegrationType; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html index 792606cbfe0..19c2b6728e2 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html @@ -10,26 +10,26 @@
- @if (linkURL) { + @if (linkURL()) { }

- {{ name }} + {{ name() }} @if (showConnectedBadge()) { @if (isConnected) { @@ -41,15 +41,15 @@ }

- @if (description) { -

{{ description }}

+ @if (description()) { +

{{ description() }}

} - @if (canSetupConnection) { + @if (canSetupConnection()) { } 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 928bb9488b3..6f5c67a2cab 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 @@ -8,6 +8,7 @@ import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-i 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 { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.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"; @@ -35,6 +36,7 @@ describe("IntegrationCardComponent", () => { const mockIntegrationService = mock(); const dialogService = mock(); const toastService = mock(); + const stateService = mock(); const systemTheme$ = new BehaviorSubject(ThemeType.Light); const usersPreferenceTheme$ = new BehaviorSubject(ThemeType.Light); @@ -59,6 +61,7 @@ describe("IntegrationCardComponent", () => { { provide: OrganizationIntegrationService, useValue: mockIntegrationService }, { provide: ToastService, useValue: toastService }, { provide: DialogService, useValue: dialogService }, + { provide: IntegrationStateService, useValue: stateService }, ], }).compileComponents(); }); @@ -67,9 +70,9 @@ describe("IntegrationCardComponent", () => { fixture = TestBed.createComponent(IntegrationCardComponent); component = fixture.componentInstance; - component.name = "Integration Name"; - component.image = "test-image.png"; - component.linkURL = "https://example.com/"; + fixture.componentRef.setInput("name", "Integration Name"); + fixture.componentRef.setInput("image", "test-image.png"); + fixture.componentRef.setInput("linkURL", "https://example.com/"); mockI18nService.t.mockImplementation((key) => key); fixture.detectChanges(); @@ -88,7 +91,7 @@ describe("IntegrationCardComponent", () => { }); it("assigns external rel attribute", () => { - component.externalURL = true; + fixture.componentRef.setInput("externalURL", true); fixture.detectChanges(); const link = fixture.nativeElement.querySelector("a"); @@ -107,26 +110,27 @@ describe("IntegrationCardComponent", () => { }); it("shows when expiration is in the future", () => { - component.newBadgeExpiration = "2023-09-02"; + fixture.componentRef.setInput("newBadgeExpiration", "2023-09-02"); expect(component.showNewBadge()).toBe(true); }); it("does not show when expiration is not set", () => { + fixture.componentRef.setInput("newBadgeExpiration", undefined); expect(component.showNewBadge()).toBe(false); }); it("does not show when expiration is in the past", () => { - component.newBadgeExpiration = "2023-08-31"; + fixture.componentRef.setInput("newBadgeExpiration", "2023-08-31"); expect(component.showNewBadge()).toBe(false); }); it("does not show when expiration is today", () => { - component.newBadgeExpiration = "2023-09-01"; + fixture.componentRef.setInput("newBadgeExpiration", "2023-09-01"); expect(component.showNewBadge()).toBe(false); }); it("does not show when expiration is invalid", () => { - component.newBadgeExpiration = "not-a-date"; + fixture.componentRef.setInput("newBadgeExpiration", "not-a-date"); expect(component.showNewBadge()).toBe(false); }); }); @@ -138,12 +142,12 @@ describe("IntegrationCardComponent", () => { fixture.detectChanges(); - expect(component.imageEle.nativeElement.src).toContain("test-image.png"); + expect(component.imageEle().nativeElement.src).toContain("test-image.png"); }); describe("user prefers the system theme", () => { beforeEach(() => { - component.imageDarkMode = "test-image-dark.png"; + fixture.componentRef.setInput("imageDarkMode", "test-image-dark.png"); }); it("sets image src to imageDarkMode", () => { @@ -152,24 +156,24 @@ describe("IntegrationCardComponent", () => { fixture.detectChanges(); - expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png"); + expect(component.imageEle().nativeElement.src).toContain("test-image-dark.png"); }); it("sets image src to light mode image", () => { - component.imageEle.nativeElement.src = "test-image-dark.png"; + component.imageEle().nativeElement.src = "test-image-dark.png"; usersPreferenceTheme$.next(ThemeType.System); systemTheme$.next(ThemeType.Light); fixture.detectChanges(); - expect(component.imageEle.nativeElement.src).toContain("test-image.png"); + expect(component.imageEle().nativeElement.src).toContain("test-image.png"); }); }); describe("user prefers dark mode", () => { beforeEach(() => { - component.imageDarkMode = "test-image-dark.png"; + fixture.componentRef.setInput("imageDarkMode", "test-image-dark.png"); }); it("updates image to dark mode", () => { @@ -178,24 +182,24 @@ describe("IntegrationCardComponent", () => { fixture.detectChanges(); - expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png"); + expect(component.imageEle().nativeElement.src).toContain("test-image-dark.png"); }); }); describe("user prefers light mode", () => { beforeEach(() => { - component.imageDarkMode = "test-image-dark.png"; + fixture.componentRef.setInput("imageDarkMode", "test-image-dark.png"); }); it("updates image to light mode", () => { - component.imageEle.nativeElement.src = "test-image-dark.png"; + component.imageEle().nativeElement.src = "test-image-dark.png"; systemTheme$.next(ThemeType.Dark); // system theme shouldn't matter usersPreferenceTheme$.next(ThemeType.Light); fixture.detectChanges(); - expect(component.imageEle.nativeElement.src).toContain("test-image.png"); + expect(component.imageEle().nativeElement.src).toContain("test-image.png"); }); }); }); @@ -211,57 +215,52 @@ describe("IntegrationCardComponent", () => { }); it("returns false when newBadgeExpiration is undefined", () => { - component.newBadgeExpiration = undefined; + fixture.componentRef.setInput("newBadgeExpiration", undefined); expect(component.showNewBadge()).toBe(false); }); it("returns false when newBadgeExpiration is an invalid date", () => { - component.newBadgeExpiration = "invalid-date"; + fixture.componentRef.setInput("newBadgeExpiration", "invalid-date"); expect(component.showNewBadge()).toBe(false); }); it("returns true when newBadgeExpiration is in the future", () => { - component.newBadgeExpiration = "2024-06-02"; + fixture.componentRef.setInput("newBadgeExpiration", "2024-06-02"); expect(component.showNewBadge()).toBe(true); }); it("returns false when newBadgeExpiration is today", () => { - component.newBadgeExpiration = "2024-06-01"; + fixture.componentRef.setInput("newBadgeExpiration", "2024-06-01"); expect(component.showNewBadge()).toBe(false); }); it("returns false when newBadgeExpiration is in the past", () => { - component.newBadgeExpiration = "2024-05-31"; + fixture.componentRef.setInput("newBadgeExpiration", "2024-05-31"); expect(component.showNewBadge()).toBe(false); }); }); describe("showConnectedBadge", () => { it("returns true when canSetupConnection is true", () => { - component.canSetupConnection = true; + fixture.componentRef.setInput("canSetupConnection", true); expect(component.showConnectedBadge()).toBe(true); }); it("returns false when canSetupConnection is false", () => { - component.canSetupConnection = false; - expect(component.showConnectedBadge()).toBe(false); - }); - - it("returns false when canSetupConnection is undefined", () => { - component.canSetupConnection = undefined; + fixture.componentRef.setInput("canSetupConnection", false); expect(component.showConnectedBadge()).toBe(false); }); }); describe("setupConnection", () => { beforeEach(() => { - component.integrationSettings = { + fixture.componentRef.setInput("integrationSettings", { organizationIntegration: { id: "integration-id", configuration: {}, integrationConfiguration: [{ id: "config-id" }], }, name: OrganizationIntegrationServiceName.CrowdStrike, - } as any; + } as any); component.organizationId = "org-id" as any; jest.resetAllMocks(); }); @@ -311,10 +310,10 @@ describe("IntegrationCardComponent", () => { }); it("should call saveHec if isUpdateAvailable is false", async () => { - component.integrationSettings = { + fixture.componentRef.setInput("integrationSettings", { organizationIntegration: null, name: OrganizationIntegrationServiceName.CrowdStrike, - } as any; + } as any); component.organizationId = "org-id" as any; (openHecConnectDialog as jest.Mock).mockReturnValue({ @@ -376,10 +375,10 @@ describe("IntegrationCardComponent", () => { }); it("should not call delete if no existing configuration", async () => { - component.integrationSettings = { + fixture.componentRef.setInput("integrationSettings", { organizationIntegration: null, name: OrganizationIntegrationServiceName.CrowdStrike, - } as any; + } as any); component.organizationId = "org-id" as any; (openHecConnectDialog as jest.Mock).mockReturnValue({ 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 f423a9b86d9..809c69cf635 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 @@ -3,9 +3,9 @@ import { Component, ElementRef, Inject, - Input, + input, OnDestroy, - ViewChild, + viewChild, } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs"; @@ -20,7 +20,11 @@ import { } 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 { + IntegrationModificationResult, + OrganizationIntegrationService, +} from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.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"; @@ -52,30 +56,13 @@ import { }) export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private destroyed$: Subject = new Subject(); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild("imageEle") imageEle!: ElementRef; - - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() name: string = ""; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() image: string = ""; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() imageDarkMode: string = ""; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() linkURL: string = ""; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() integrationSettings!: Integration; - - /** Adds relevant `rel` attribute to external links */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() externalURL?: boolean; + readonly imageEle = viewChild.required>("imageEle"); + readonly name = input.required(); + readonly image = input.required(); + readonly imageDarkMode = input.required(); + readonly linkURL = input.required(); + readonly integrationSettings = input.required(); + readonly externalURL = input.required(); /** * Date of when the new badge should be hidden. @@ -83,15 +70,9 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { * * @example "2024-12-31" */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() newBadgeExpiration?: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() description?: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() canSetupConnection?: boolean; + readonly newBadgeExpiration = input(undefined); + readonly description = input(""); + readonly canSetupConnection = input(false); organizationId: OrganizationId; @@ -104,6 +85,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private organizationIntegrationService: OrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, + protected state: IntegrationStateService, ) { this.organizationId = this.activatedRoute.snapshot.paramMap.get( "organizationId", @@ -115,7 +97,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { .pipe(takeUntil(this.destroyed$)) .subscribe(([theme, systemTheme]) => { // When the card doesn't have a dark mode image, exit early - if (!this.imageDarkMode) { + if (!this.imageDarkMode()) { return; } @@ -124,13 +106,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { // use the system theme to determine the image const prefersDarkMode = systemTheme === ThemeType.Dark; - this.imageEle.nativeElement.src = prefersDarkMode ? this.imageDarkMode : this.image; + this.imageEle().nativeElement.src = prefersDarkMode ? this.imageDarkMode() : this.image(); } else if (theme === ThemeType.Dark) { // When the user's preference is dark mode, use the dark mode image - this.imageEle.nativeElement.src = this.imageDarkMode; + this.imageEle().nativeElement.src = this.imageDarkMode(); } else { // Otherwise use the light mode image - this.imageEle.nativeElement.src = this.image; + this.imageEle().nativeElement.src = this.image(); } }); } @@ -142,11 +124,11 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { /** Show the "new" badge when expiration is in the future */ showNewBadge() { - if (!this.newBadgeExpiration) { + if (!this.newBadgeExpiration()) { return false; } - const expirationDate = new Date(this.newBadgeExpiration); + const expirationDate = new Date(this.newBadgeExpiration() ?? "undefined"); // Do not show the new badge for invalid dates if (isNaN(expirationDate.getTime())) { @@ -157,26 +139,26 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } get isConnected(): boolean { - return !!this.integrationSettings.organizationIntegration?.configuration; + return !!this.integrationSettings().organizationIntegration?.configuration; } showConnectedBadge(): boolean { - return this.canSetupConnection ?? false; + return this.canSetupConnection(); } get isUpdateAvailable(): boolean { - return !!this.integrationSettings.organizationIntegration; + return !!this.integrationSettings().organizationIntegration; } async setupConnection() { - if (this.integrationSettings?.integrationType === null) { + if (this.integrationSettings()?.integrationType === null) { return; } - if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { + if (this.integrationSettings()?.integrationType === OrganizationIntegrationType.Datadog) { const dialog = openDatadogConnectDialog(this.dialogService, { data: { - settings: this.integrationSettings, + settings: this.integrationSettings(), }, }); @@ -187,11 +169,11 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { () => this.deleteDatadog(), (res) => this.saveDatadog(res), ); - } else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) { + } else if (this.integrationSettings().name === OrganizationIntegrationServiceName.Huntress) { // Huntress uses HEC protocol but has its own dialog const dialog = openHuntressConnectDialog(this.dialogService, { data: { - settings: this.integrationSettings, + settings: this.integrationSettings(), }, }); @@ -206,7 +188,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { // invoke the dialog to connect the integration const dialog = openHecConnectDialog(this.dialogService, { data: { - settings: this.integrationSettings, + settings: this.integrationSettings(), }, }); @@ -228,13 +210,17 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { config: OrgIntegrationConfiguration, template: OrgIntegrationTemplate, ): Promise { - let response = { mustBeOwner: false, success: false }; + let response: IntegrationModificationResult = { + mustBeOwner: false, + success: false, + organizationIntegrationResult: undefined, + }; if (this.isUpdateAvailable) { // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationId = this.integrationSettings().organizationIntegration?.id; const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + this.integrationSettings().organizationIntegration?.integrationConfiguration[0]?.id; if (!orgIntegrationId || !orgIntegrationConfigurationId) { throw Error("Organization Integration ID or Configuration ID is missing"); @@ -264,10 +250,21 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return; } + // update local state with the new integration settings + if (response.success && response.organizationIntegrationResult) { + this.state.updateIntegrationSettings( + this.integrationSettings().name, + response.organizationIntegrationResult, + ); + } + this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("success"), + message: this.i18nService.t( + "integrationConnectedSuccessfully", + this.integrationSettings().name, + ), }); } @@ -275,9 +272,9 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { * Generic delete method */ private async deleteIntegration(): Promise { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationId = this.integrationSettings().organizationIntegration?.id; const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + this.integrationSettings().organizationIntegration?.integrationConfiguration[0]?.id; if (!orgIntegrationId || !orgIntegrationConfigurationId) { throw Error("Organization Integration ID or Configuration ID is missing"); @@ -294,6 +291,10 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return; } + if (response.success) { + this.state.deleteIntegrationSettings(this.integrationSettings().name); + } + this.toastService.showToast({ variant: "success", title: "", @@ -347,11 +348,11 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const config = OrgIntegrationBuilder.buildHecConfiguration( result.url, result.bearerToken, - this.integrationSettings.name as OrganizationIntegrationServiceName, + this.integrationSettings().name as OrganizationIntegrationServiceName, ); const template = OrgIntegrationBuilder.buildHecTemplate( result.index, - this.integrationSettings.name as OrganizationIntegrationServiceName, + this.integrationSettings().name as OrganizationIntegrationServiceName, ); await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); @@ -385,7 +386,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { async saveDatadog(result: DatadogConnectDialogResult) { const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey); const template = OrgIntegrationBuilder.buildDataDogTemplate( - this.integrationSettings.name as OrganizationIntegrationServiceName, + this.integrationSettings().name as OrganizationIntegrationServiceName, ); await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html index 8127c6a0343..2fdabf73490 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html @@ -1,10 +1,10 @@
    - @for (integration of integrations; track integration) { + @for (integration of integrations(); track integration) {
  • { provide: ToastService, useValue: mock(), }, + { provide: IntegrationStateService, useValue: mock() }, ], }); fixture = TestBed.createComponent(IntegrationGridComponent); component = fixture.componentInstance; - component.integrations = integrations; - component.ariaI18nKey = "integrationCardAriaLabel"; - component.tooltipI18nKey = "integrationCardTooltip"; + fixture.componentRef.setInput("integrations", integrations); + fixture.componentRef.setInput("ariaI18nKey", "integrationCardAriaLabel"); + fixture.componentRef.setInput("tooltipI18nKey", "integrationCardTooltip"); fixture.detectChanges(); }); it("lists all integrations", () => { - expect(component.integrations).toEqual(integrations); + expect(component.integrations()).toEqual(integrations); const cards = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent)); @@ -94,20 +96,20 @@ describe("IntegrationGridComponent", () => { }); it("assigns the correct attributes to IntegrationCardComponent", () => { - expect(component.integrations).toEqual(integrations); + expect(component.integrations()).toEqual(integrations); const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent))[1]; - expect(card.componentInstance.name).toBe("SDK 2"); - expect(card.componentInstance.image).toBe("test-image2.png"); - expect(card.componentInstance.linkURL).toBe("https://example.com/2"); + expect(card.componentInstance.name()).toBe("SDK 2"); + expect(card.componentInstance.image()).toBe("test-image2.png"); + expect(card.componentInstance.linkURL()).toBe("https://example.com/2"); }); it("assigns `externalURL` for SDKs", () => { const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent)); - expect(card[0].componentInstance.externalURL).toBe(false); - expect(card[1].componentInstance.externalURL).toBe(true); + expect(card[0].componentInstance.externalURL()).toBe(false); + expect(card[1].componentInstance.externalURL()).toBe(true); }); it("has a tool tip and aria label attributes", () => { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts index 19f15d1caea..ff029eb0b36 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { IntegrationType } from "@bitwarden/common/enums"; @@ -6,24 +6,16 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationCardComponent } from "../integration-card/integration-card.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-integration-grid", templateUrl: "./integration-grid.component.html", imports: [IntegrationCardComponent, SharedModule], }) export class IntegrationGridComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() integrations: Integration[] = []; - - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() ariaI18nKey: string = "integrationCardAriaLabel"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() tooltipI18nKey: string = "integrationCardTooltip"; + readonly integrations = input.required(); + readonly ariaI18nKey = input("integrationCardAriaLabel"); + readonly tooltipI18nKey = input("integrationCardTooltip"); protected IntegrationType = IntegrationType; } 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 786aa70bfc5..60a9f42e8c3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -1,11 +1,10 @@ import { Component } from "@angular/core"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { OrganizationIntegrationsState } from "./organization-integrations.state"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -16,7 +15,7 @@ import { OrganizationIntegrationsState } from "./organization-integrations.state export class AdminConsoleIntegrationsComponent { organization = this.state.organization; - constructor(private state: OrganizationIntegrationsState) {} + constructor(private state: IntegrationStateService) {} // use in the view get IntegrationType(): typeof IntegrationType { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts index 626fc5dee88..fed98345fc7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts @@ -7,7 +7,6 @@ import { DeviceManagementComponent } from "./device-management/device-management import { EventManagementComponent } from "./event-management/event-management.component"; import { AdminConsoleIntegrationsComponent } from "./integrations.component"; import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; -import { OrganizationIntegrationsState } from "./organization-integrations.state"; import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; @@ -19,7 +18,7 @@ const routes: Routes = [ titleId: "integrations", }, component: AdminConsoleIntegrationsComponent, - providers: [OrganizationIntegrationsState, OrganizationIntegrationsResolver], + providers: [OrganizationIntegrationsResolver], resolve: { integrations: OrganizationIntegrationsResolver }, children: [ { path: "", redirectTo: "single-sign-on", pathMatch: "full" }, 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 33f389a92a9..e24bb33238d 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 @@ -4,6 +4,7 @@ import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-manage 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 { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; @@ -11,6 +12,7 @@ import { EventManagementComponent } from "./event-management/event-management.co import { AdminConsoleIntegrationsComponent } from "./integrations.component"; import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module"; import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; +import { OrganizationIntegrationsState } from "./organization-integrations.state"; import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; @@ -40,6 +42,11 @@ import { UserProvisioningComponent } from "./user-provisioning/user-provisioning useClass: OrganizationIntegrationConfigurationApiService, deps: [ApiService], }), + safeProvider({ + provide: IntegrationStateService, + useClass: OrganizationIntegrationsState, + useAngularDecorators: true, + }), ], }) export class OrganizationIntegrationsModule {} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts index 39bd0cc1dcc..16e5113f4d7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts @@ -7,6 +7,7 @@ import { Integration } from "@bitwarden/bit-common/dirt/organization-integration import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -15,8 +16,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { OrganizationIntegrationsState } from "./organization-integrations.state"; - @Injectable() export class OrganizationIntegrationsResolver implements Resolve { constructor( @@ -24,7 +23,7 @@ export class OrganizationIntegrationsResolver implements Resolve { private accountService: AccountService, private configService: ConfigService, private organizationIntegrationService: OrganizationIntegrationService, - private state: OrganizationIntegrationsState, + private state: IntegrationStateService, ) {} async resolve(route: ActivatedRouteSnapshot): Promise { @@ -262,6 +261,7 @@ export class OrganizationIntegrationsResolver implements Resolve { name: OrganizationIntegrationServiceName.Huntress, linkURL: "https://bitwarden.com/help/huntress-siem/", image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + imageDarkMode: "../../../../../../../images/integrations/logo-huntress-siem-darkmode.svg", type: IntegrationType.EVENT, description: "huntressEventIntegrationDesc", canSetupConnection: true, diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts index 5e7e6a78ba4..5a059986f47 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts @@ -1,10 +1,12 @@ import { Injectable, signal } from "@angular/core"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { OrganizationIntegration } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @Injectable() -export class OrganizationIntegrationsState { +export class OrganizationIntegrationsState implements IntegrationStateService { private readonly _integrations = signal([]); private readonly _organization = signal(undefined); @@ -12,11 +14,38 @@ export class OrganizationIntegrationsState { integrations = this._integrations.asReadonly(); organization = this._organization.asReadonly(); - setOrganization(val: Organization | null) { + setOrganization(val: Organization | undefined) { this._organization.set(val ?? undefined); } setIntegrations(val: Integration[]) { this._integrations.set(val); } + + updateIntegrationSettings( + integrationName: string, + updatedIntegrationSettings: OrganizationIntegration, + ) { + const integrations = this._integrations(); + const index = integrations.findIndex((i) => i.name === integrationName); + if (index >= 0) { + const updatedIntegrations = integrations.map((integration, i) => + i === index + ? { ...integration, organizationIntegration: updatedIntegrationSettings } + : integration, + ); + this.setIntegrations(updatedIntegrations); + } + } + + deleteIntegrationSettings(integrationName: string) { + const integrations = this._integrations(); + const index = integrations.findIndex((i) => i.name === integrationName); + if (index >= 0) { + const updatedIntegrations = integrations.map((integration, i) => + i === index ? { ...integration, organizationIntegration: undefined } : integration, + ); + this.setIntegrations(updatedIntegrations); + } + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts index d0d2a1666f2..f8c529a1456 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts @@ -1,11 +1,11 @@ import { Component } from "@angular/core"; +import { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "../integrations.pipe"; -import { OrganizationIntegrationsState } from "../organization-integrations.state"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -18,5 +18,5 @@ export class SingleSignOnComponent { integrations = this.state.integrations; IntegrationType = IntegrationType; - constructor(private state: OrganizationIntegrationsState) {} + constructor(private state: IntegrationStateService) {} } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts index f484674d224..8e1b544b75e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts @@ -1,11 +1,11 @@ import { Component } from "@angular/core"; +import { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "../integrations.pipe"; -import { OrganizationIntegrationsState } from "../organization-integrations.state"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -18,7 +18,7 @@ export class UserProvisioningComponent { organization = this.state.organization; integrations = this.state.integrations; - constructor(private state: OrganizationIntegrationsState) {} + constructor(private state: IntegrationStateService) {} get IntegrationType(): typeof IntegrationType { return IntegrationType; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.html index db3db75897d..67445f66609 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.html @@ -5,7 +5,7 @@

    {{ "integrationsDesc" | i18n }}

    @@ -17,7 +17,7 @@

    {{ "sdksDesc" | i18n }}

    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 7a02e3fb04e..ca338827ad4 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 @@ -1,15 +1,17 @@ -import { Component } from "@angular/core"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; 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 { of } from "rxjs"; -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 { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; +import { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; +import { IntegrationType } from "@bitwarden/common/enums"; 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"; @@ -19,19 +21,18 @@ import { IntegrationCardComponent } from "../../dirt/organization-integrations/i import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component"; import { IntegrationsComponent } from "./integrations.component"; +import { SecretsIntegrationsState } from "./secrets-integrations.state"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-header", template: "
    ", standalone: false, }) class MockHeaderComponent {} -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "sm-new-menu", template: "
    ", standalone: false, @@ -39,52 +40,158 @@ class MockHeaderComponent {} class MockNewMenuComponent {} describe("IntegrationsComponent", () => { + let component: IntegrationsComponent; let fixture: ComponentFixture; - const orgIntegrationSvc = mock(); + let integrationStateService: IntegrationStateService; const activatedRouteMock = { snapshot: { paramMap: { get: jest.fn() } }, }; - const mockI18nService = mock(); beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [IntegrationsComponent, MockHeaderComponent, MockNewMenuComponent], - imports: [JslibModule, IntegrationGridComponent, IntegrationCardComponent], + imports: [ + JslibModule, + IntegrationGridComponent, + IntegrationCardComponent, + FilterIntegrationsPipe, + I18nPipe, + ], providers: [ { provide: I18nService, useValue: mock() }, { provide: ThemeStateService, useValue: mock() }, { provide: SYSTEM_THEME_OBSERVABLE, useValue: of(ThemeType.Light) }, { provide: ActivatedRoute, useValue: activatedRouteMock }, - { provide: I18nPipe, useValue: mock() }, - { provide: I18nService, useValue: mockI18nService }, - { provide: OrganizationIntegrationService, useValue: orgIntegrationSvc }, + { + provide: OrganizationIntegrationService, + useValue: mock(), + }, + { provide: IntegrationStateService, useClass: SecretsIntegrationsState }, ], }).compileComponents(); + fixture = TestBed.createComponent(IntegrationsComponent); + component = fixture.componentInstance; + integrationStateService = TestBed.inject(IntegrationStateService); fixture.detectChanges(); }); - it("divides Integrations & SDKS", () => { - const [integrationList, sdkList] = fixture.debugElement.queryAll( - By.directive(IntegrationGridComponent), + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize integrations in state on construction", () => { + const integrations = integrationStateService.integrations(); + + expect(integrations.length).toBeGreaterThan(0); + expect(integrations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "GitHub Actions", type: IntegrationType.Integration }), + expect.objectContaining({ name: "Rust", type: IntegrationType.SDK }), + ]), ); + }); - // Validate only expected names, as the data is constant - expect( - (integrationList.componentInstance as IntegrationGridComponent).integrations.map( - (i) => i.name, - ), - ).toEqual([ - "GitHub Actions", - "GitLab CI/CD", - "Ansible", - "Kubernetes Operator", - "Terraform Provider", - ]); + it("should expose integrations getter from state", () => { + const stateIntegrations = integrationStateService.integrations(); + const componentIntegrations = component.integrations(); - expect( - (sdkList.componentInstance as IntegrationGridComponent).integrations.map((i) => i.name), - ).toEqual(["Rust", "C#", "C++", "Go", "Java", "JS WebAssembly", "php", "Python", "Ruby"]); + expect(componentIntegrations).toEqual(stateIntegrations); + }); + + it("should expose IntegrationType enum for template usage", () => { + expect(component.IntegrationType).toBe(IntegrationType); + }); + + describe("template rendering", () => { + it("should render two integration grid sections", () => { + const grids = fixture.debugElement.queryAll(By.directive(IntegrationGridComponent)); + + expect(grids.length).toBe(2); + }); + + it("should pass correct integrations to first grid (Integrations)", () => { + const [integrationsGrid] = fixture.debugElement.queryAll( + By.directive(IntegrationGridComponent), + ); + + const gridInstance = integrationsGrid.componentInstance as IntegrationGridComponent; + const integrationNames = gridInstance.integrations().map((i: Integration) => i.name); + + expect(integrationNames).toContain("GitHub Actions"); + expect(integrationNames).toContain("GitLab CI/CD"); + expect(integrationNames).toContain("Ansible"); + expect(integrationNames).toContain("Kubernetes Operator"); + expect(integrationNames).toContain("Terraform Provider"); + expect(integrationNames).not.toContain("Rust"); + expect(integrationNames).not.toContain("Python"); + }); + + it("should pass correct integrations to second grid (SDKs)", () => { + const [, sdksGrid] = fixture.debugElement.queryAll(By.directive(IntegrationGridComponent)); + + const gridInstance = sdksGrid.componentInstance as IntegrationGridComponent; + const sdkNames = gridInstance.integrations().map((i: Integration) => i.name); + + expect(sdkNames).toContain("Rust"); + expect(sdkNames).toContain("C#"); + expect(sdkNames).toContain("C++"); + expect(sdkNames).toContain("Go"); + expect(sdkNames).toContain("Java"); + expect(sdkNames).toContain("JS WebAssembly"); + expect(sdkNames).toContain("php"); + expect(sdkNames).toContain("Python"); + expect(sdkNames).toContain("Ruby"); + expect(sdkNames).not.toContain("GitHub Actions"); + }); + + it("should pass correct tooltip keys to integration grids", () => { + const [integrationsGrid, sdksGrid] = fixture.debugElement.queryAll( + By.directive(IntegrationGridComponent), + ); + + expect( + (integrationsGrid.componentInstance as IntegrationGridComponent).tooltipI18nKey(), + ).toBe("smIntegrationTooltip"); + expect((sdksGrid.componentInstance as IntegrationGridComponent).tooltipI18nKey()).toBe( + "smSdkTooltip", + ); + }); + + it("should pass correct aria label keys to integration grids", () => { + const [integrationsGrid, sdksGrid] = fixture.debugElement.queryAll( + By.directive(IntegrationGridComponent), + ); + + expect((integrationsGrid.componentInstance as IntegrationGridComponent).ariaI18nKey()).toBe( + "smIntegrationCardAriaLabel", + ); + expect((sdksGrid.componentInstance as IntegrationGridComponent).ariaI18nKey()).toBe( + "smSdkAriaLabel", + ); + }); + }); + + describe("integration data validation", () => { + it("should include required properties for all integrations", () => { + const integrations = component.integrations(); + + integrations.forEach((integration: Integration) => { + expect(integration.name).toBeDefined(); + expect(integration.linkURL).toBeDefined(); + expect(integration.image).toBeDefined(); + expect(integration.type).toBeDefined(); + expect([IntegrationType.Integration, IntegrationType.SDK]).toContain(integration.type); + }); + }); + + it("should have valid link URLs for all integrations", () => { + const integrations = component.integrations(); + + integrations.forEach((integration: Integration) => { + expect(integration.linkURL).toMatch(/^https?:\/\//); + }); + }); }); }); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts index 37c7a93d27f..073683522ee 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts @@ -1,6 +1,7 @@ -import { Component } from "@angular/core"; +import { Component, Signal } from "@angular/core"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { IntegrationType } from "@bitwarden/common/enums"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -11,10 +12,8 @@ import { IntegrationType } from "@bitwarden/common/enums"; standalone: false, }) export class IntegrationsComponent { - private integrationsAndSdks: Integration[] = []; - - constructor() { - this.integrationsAndSdks = [ + constructor(private state: IntegrationStateService) { + const integrations = [ { name: "Rust", linkURL: "https://github.com/bitwarden/sdk-sm", @@ -106,19 +105,16 @@ export class IntegrationsComponent { newBadgeExpiration: "2025-12-12", // December 12, 2025 }, ]; + + this.state.setIntegrations(integrations); } - /** Filter out content for the integrations sections */ - get integrations(): Integration[] { - return this.integrationsAndSdks.filter( - (integration) => integration.type === IntegrationType.Integration, - ); + get integrations(): Signal { + return this.state.integrations; } - /** Filter out content for the SDKs section */ - get sdks(): Integration[] { - return this.integrationsAndSdks.filter( - (integration) => integration.type === IntegrationType.SDK, - ); + // use in the view + get IntegrationType(): typeof IntegrationType { + return IntegrationType; } } 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 bcfbb9b3f2c..105ebd668a5 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 @@ -3,6 +3,8 @@ import { NgModule } from "@angular/core"; 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 { FilterIntegrationsPipe } from "@bitwarden/bit-common/dirt/organization-integrations/shared/filter-integrations.pipe"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; @@ -12,6 +14,7 @@ import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; import { IntegrationsRoutingModule } from "./integrations-routing.module"; import { IntegrationsComponent } from "./integrations.component"; +import { SecretsIntegrationsState } from "./secrets-integrations.state"; @NgModule({ imports: [ @@ -19,6 +22,7 @@ import { IntegrationsComponent } from "./integrations.component"; IntegrationsRoutingModule, IntegrationCardComponent, IntegrationGridComponent, + FilterIntegrationsPipe, ], providers: [ safeProvider({ @@ -36,6 +40,11 @@ import { IntegrationsComponent } from "./integrations.component"; useClass: OrganizationIntegrationConfigurationApiService, deps: [ApiService], }), + safeProvider({ + provide: IntegrationStateService, + useClass: SecretsIntegrationsState, + useAngularDecorators: true, + }), ], declarations: [IntegrationsComponent], }) diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/secrets-integrations.state.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/secrets-integrations.state.ts new file mode 100644 index 00000000000..89bb9c449e9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/secrets-integrations.state.ts @@ -0,0 +1,50 @@ +import { signal } from "@angular/core"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { OrganizationIntegration } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration"; +import { IntegrationStateService } from "@bitwarden/bit-common/dirt/organization-integrations/shared/integration-state.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +export class SecretsIntegrationsState implements IntegrationStateService { + private readonly _integrations = signal([]); + private readonly _organization = signal(undefined); + + // Signals + integrations = this._integrations.asReadonly(); + organization = this._organization.asReadonly(); + + setOrganization(val: Organization | undefined) { + this._organization.set(val ?? undefined); + } + + setIntegrations(val: Integration[]) { + this._integrations.set(val); + } + + updateIntegrationSettings( + integrationName: string, + updatedIntegrationSettings: OrganizationIntegration, + ) { + const integrations = this._integrations(); + const index = integrations.findIndex((i) => i.name === integrationName); + if (index >= 0) { + const updatedIntegrations = integrations.map((integration, i) => + i === index + ? { ...integration, organizationIntegration: updatedIntegrationSettings } + : integration, + ); + this.setIntegrations(updatedIntegrations); + } + } + + deleteIntegrationSettings(integrationName: string) { + const integrations = this._integrations(); + const index = integrations.findIndex((i) => i.name === integrationName); + if (index >= 0) { + const updatedIntegrations = integrations.map((integration, i) => + i === index ? { ...integration, organizationIntegration: undefined } : integration, + ); + this.setIntegrations(updatedIntegrations); + } + } +} diff --git a/libs/common/src/enums/integration-type.enum.ts b/libs/common/src/enums/integration-type.enum.ts index ac8bb9c6afa..c551e373ed9 100644 --- a/libs/common/src/enums/integration-type.enum.ts +++ b/libs/common/src/enums/integration-type.enum.ts @@ -1,11 +1,11 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum IntegrationType { - Integration = "integration", - SDK = "sdk", - SSO = "sso", - SCIM = "scim", - BWDC = "bwdc", - EVENT = "event", - DEVICE = "device", -} +export const IntegrationType = Object.freeze({ + Integration: "integration", + SDK: "sdk", + SSO: "sso", + SCIM: "scim", + BWDC: "bwdc", + EVENT: "event", + DEVICE: "device", +} as const); + +export type IntegrationType = (typeof IntegrationType)[keyof typeof IntegrationType];