-
{{ "allApplications" | i18n }}
-
@@ -20,7 +18,8 @@
(ngModelChange)="setFilterApplicationsByStatus($event)"
fullWidth="false"
class="tw-min-w-48"
- >
+ >
+
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
index 8962980c872..8cd0c2640f5 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
@@ -96,12 +96,15 @@ export class ApplicationsComponent implements OnInit {
{
label: this.i18nService.t("critical", this.criticalApplicationsCount()),
value: ApplicationFilterOption.Critical,
+ icon: " ",
},
{
label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()),
value: ApplicationFilterOption.NonCritical,
+ icon: " ",
},
]);
+ protected readonly emptyTableExplanation = signal("");
constructor(
protected i18nService: I18nService,
@@ -162,6 +165,12 @@ export class ApplicationsComponent implements OnInit {
this.dataSource.filter = (app) =>
filterFunction(app) &&
app.applicationName.toLowerCase().includes(searchText.toLowerCase());
+
+ if (this.dataSource?.filteredData?.length === 0) {
+ this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters"));
+ } else {
+ this.emptyTableExplanation.set("");
+ }
});
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html
new file mode 100644
index 00000000000..6c04ea87960
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html
@@ -0,0 +1,11 @@
+@let integrationsList = integrations();
+
+
+
+ {{ "deviceManagement" | i18n }}
+
+ {{ "deviceManagementDesc" | i18n }}
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts
new file mode 100644
index 00000000000..18e6dc7e362
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts
@@ -0,0 +1,25 @@
+import { Component } from "@angular/core";
+
+import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
+import { FilterIntegrationsPipe } from "../integrations.pipe";
+import { OrganizationIntegrationsState } from "../organization-integrations.state";
+
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ selector: "device-management",
+ templateUrl: "device-management.component.html",
+ imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
+})
+export class DeviceManagementComponent {
+ integrations = this.state.integrations;
+
+ constructor(private state: OrganizationIntegrationsState) {}
+
+ get IntegrationType(): typeof IntegrationType {
+ return IntegrationType;
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html
new file mode 100644
index 00000000000..9a767e52c8b
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html
@@ -0,0 +1,11 @@
+@let integrationsList = integrations();
+
+
+
+ {{ "eventManagement" | i18n }}
+
+ {{ "eventManagementDesc" | i18n }}
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts
new file mode 100644
index 00000000000..70b17cabd35
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts
@@ -0,0 +1,24 @@
+import { Component } from "@angular/core";
+
+import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
+import { FilterIntegrationsPipe } from "../integrations.pipe";
+import { OrganizationIntegrationsState } from "../organization-integrations.state";
+
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ selector: "event-management",
+ templateUrl: "event-management.component.html",
+ imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
+})
+export class EventManagementComponent {
+ integrations = this.state.integrations;
+ constructor(private state: OrganizationIntegrationsState) {}
+
+ get IntegrationType(): typeof IntegrationType {
+ return IntegrationType;
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html
index 14f20a0b71c..fbff31f026e 100644
--- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html
@@ -1,82 +1,18 @@
-
+@let org = organization();
-@let organization = organization$ | async;
+
+ @if (org) {
+
+ {{ "singleSignOn" | i18n }}
+ @if (org.useScim || org.useDirectory) {
+ {{ "userProvisioning" | i18n }}
+ }
+ @if (org.useEvents) {
+ {{ "eventManagement" | i18n }}
+ }
+ {{ "deviceManagement" | i18n }}
+
+ }
+
-@if (organization) {
-
- @if (organization?.useSso) {
-
-
-
- }
-
- @if (organization?.useScim || organization?.useDirectory) {
-
- @if (organization?.useScim) {
-
-
- {{ "scimIntegration" | i18n }}
-
-
- {{ "scimIntegrationDescStart" | i18n }}
- {{ "scimIntegration" | i18n }}
- {{ "scimIntegrationDescEnd" | i18n }}
-
-
-
- }
- @if (organization?.useDirectory) {
-
-
- {{ "bwdc" | i18n }}
-
- {{ "bwdcDesc" | i18n }}
-
-
- }
-
- }
-
- @if (organization?.useEvents) {
-
-
-
- {{ "eventManagement" | i18n }}
-
- {{ "eventManagementDesc" | i18n }}
-
-
-
- }
-
-
-
-
- {{ "deviceManagement" | i18n }}
-
- {{ "deviceManagementDesc" | i18n }}
-
-
-
-
-}
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts
index 5485410f735..786aa70bfc5 100644
--- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts
@@ -1,336 +1,22 @@
-import { Component, OnDestroy, OnInit } from "@angular/core";
-import { ActivatedRoute } from "@angular/router";
-import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
+import { Component } from "@angular/core";
-import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
-import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
-import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
-import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
-import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
-import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
-import { getUserId } from "@bitwarden/common/auth/services/account.service";
-import { IntegrationType } from "@bitwarden/common/enums";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
-import { getById } from "@bitwarden/common/platform/misc";
+import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
-import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
-import { FilterIntegrationsPipe } from "./integrations.pipe";
+import { OrganizationIntegrationsState } from "./organization-integrations.state";
-// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "ac-integrations",
templateUrl: "./integrations.component.html",
- imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe],
+ imports: [SharedModule, HeaderModule],
})
-export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
- tabIndex: number = 0;
- organization$: Observable
= new Observable();
- isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
- isEventManagementForHuntressEnabled: boolean = false;
- private destroy$ = new Subject();
+export class AdminConsoleIntegrationsComponent {
+ organization = this.state.organization;
- // initialize the integrations list with default integrations
- integrationsList: Integration[] = [
- {
- name: "AD FS",
- linkURL: "https://bitwarden.com/help/saml-adfs/",
- image: "../../../../../../../images/integrations/azure-active-directory.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Auth0",
- linkURL: "https://bitwarden.com/help/saml-auth0/",
- image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "AWS",
- linkURL: "https://bitwarden.com/help/saml-aws/",
- image: "../../../../../../../images/integrations/aws-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Microsoft Entra ID",
- linkURL: "https://bitwarden.com/help/saml-azure/",
- image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Duo",
- linkURL: "https://bitwarden.com/help/saml-duo/",
- image: "../../../../../../../images/integrations/logo-duo-color.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Google",
- linkURL: "https://bitwarden.com/help/saml-google/",
- image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "JumpCloud",
- linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
- image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "KeyCloak",
- linkURL: "https://bitwarden.com/help/saml-keycloak/",
- image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Okta",
- linkURL: "https://bitwarden.com/help/saml-okta/",
- image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
- imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "OneLogin",
- linkURL: "https://bitwarden.com/help/saml-onelogin/",
- image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "PingFederate",
- linkURL: "https://bitwarden.com/help/saml-pingfederate/",
- image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
- type: IntegrationType.SSO,
- },
- {
- name: "Microsoft Entra ID",
- linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
- image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
- type: IntegrationType.SCIM,
- },
- {
- name: "Okta",
- linkURL: "https://bitwarden.com/help/okta-scim-integration/",
- image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
- imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
- type: IntegrationType.SCIM,
- },
- {
- name: "OneLogin",
- linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
- image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
- type: IntegrationType.SCIM,
- },
- {
- name: "JumpCloud",
- linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
- image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
- type: IntegrationType.SCIM,
- },
- {
- name: "Ping Identity",
- linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
- image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
- type: IntegrationType.SCIM,
- },
- {
- name: "Active Directory",
- linkURL: "https://bitwarden.com/help/ldap-directory/",
- image: "../../../../../../../images/integrations/azure-active-directory.svg",
- type: IntegrationType.BWDC,
- },
- {
- name: "Microsoft Entra ID",
- linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
- image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
- type: IntegrationType.BWDC,
- },
- {
- name: "Google Workspace",
- linkURL: "https://bitwarden.com/help/workspace-directory/",
- image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
- type: IntegrationType.BWDC,
- },
- {
- name: "Okta",
- linkURL: "https://bitwarden.com/help/okta-directory/",
- image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
- imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
- type: IntegrationType.BWDC,
- },
- {
- name: "OneLogin",
- linkURL: "https://bitwarden.com/help/onelogin-directory/",
- image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
- imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
- type: IntegrationType.BWDC,
- },
- {
- name: "Splunk",
- linkURL: "https://bitwarden.com/help/splunk-siem/",
- image: "../../../../../../../images/integrations/logo-splunk-black.svg",
- imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
- type: IntegrationType.EVENT,
- },
- {
- name: "Microsoft Sentinel",
- linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
- image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
- type: IntegrationType.EVENT,
- },
- {
- name: "Rapid7",
- linkURL: "https://bitwarden.com/help/rapid7-siem/",
- image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
- imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
- type: IntegrationType.EVENT,
- },
- {
- name: "Elastic",
- linkURL: "https://bitwarden.com/help/elastic-siem/",
- image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
- type: IntegrationType.EVENT,
- },
- {
- name: "Panther",
- linkURL: "https://bitwarden.com/help/panther-siem/",
- image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
- type: IntegrationType.EVENT,
- },
- {
- name: "Sumo Logic",
- linkURL: "https://bitwarden.com/help/sumo-logic-siem/",
- image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg",
- imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg",
- type: IntegrationType.EVENT,
- newBadgeExpiration: "2025-12-31",
- },
- {
- name: "Microsoft Intune",
- linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
- image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
- type: IntegrationType.DEVICE,
- },
- ];
-
- async ngOnInit() {
- const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
- if (!userId) {
- throw new Error("User ID not found");
- }
-
- this.organization$ = this.route.params.pipe(
- switchMap((params) =>
- this.organizationService.organizations$(userId).pipe(
- getById(params.organizationId),
- // Filter out undefined values
- takeWhile((org: Organization | undefined) => !!org),
- ),
- ),
- );
-
- // Sets the organization ID which also loads the integrations$
- this.organization$
- .pipe(
- switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)),
- takeUntil(this.destroy$),
- )
- .subscribe();
- }
-
- constructor(
- private route: ActivatedRoute,
- private organizationService: OrganizationService,
- private accountService: AccountService,
- private configService: ConfigService,
- private organizationIntegrationService: OrganizationIntegrationService,
- ) {
- this.configService
- .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike)
- .pipe(takeUntil(this.destroy$))
- .subscribe((isEnabled) => {
- this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled;
- });
-
- this.configService
- .getFeatureFlag$(FeatureFlag.EventManagementForHuntress)
- .pipe(takeUntil(this.destroy$))
- .subscribe((isEnabled) => {
- this.isEventManagementForHuntressEnabled = isEnabled;
- });
-
- // Add the new event based items to the list
- if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
- const crowdstrikeIntegration: Integration = {
- name: OrganizationIntegrationServiceName.CrowdStrike,
- linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
- image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
- type: IntegrationType.EVENT,
- description: "crowdstrikeEventIntegrationDesc",
- canSetupConnection: true,
- integrationType: OrganizationIntegrationType.Hec,
- };
-
- this.integrationsList.push(crowdstrikeIntegration);
-
- const datadogIntegration: Integration = {
- name: OrganizationIntegrationServiceName.Datadog,
- linkURL: "https://bitwarden.com/help/datadog-siem/",
- image: "../../../../../../../images/integrations/logo-datadog-color.svg",
- type: IntegrationType.EVENT,
- description: "datadogEventIntegrationDesc",
- canSetupConnection: true,
- integrationType: OrganizationIntegrationType.Datadog,
- };
-
- this.integrationsList.push(datadogIntegration);
- }
-
- // Add Huntress SIEM integration (separate feature flag)
- if (this.isEventManagementForHuntressEnabled) {
- const huntressIntegration: Integration = {
- name: OrganizationIntegrationServiceName.Huntress,
- linkURL: "https://bitwarden.com/help/huntress-siem/",
- image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
- type: IntegrationType.EVENT,
- description: "huntressEventIntegrationDesc",
- canSetupConnection: true,
- integrationType: OrganizationIntegrationType.Hec,
- };
-
- this.integrationsList.push(huntressIntegration);
- }
-
- // For all existing event based configurations loop through and assign the
- // organizationIntegration for the correct services.
- this.organizationIntegrationService.integrations$
- .pipe(takeUntil(this.destroy$))
- .subscribe((integrations) => {
- // reset all event based integrations to null first - in case one was deleted
- this.integrationsList.forEach((i) => {
- i.organizationIntegration = null;
- });
-
- integrations.forEach((integration) => {
- const item = this.integrationsList.find((i) => i.name === integration.serviceName);
- if (item) {
- item.organizationIntegration = integration;
- }
- });
- });
- }
-
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
+ constructor(private state: OrganizationIntegrationsState) {}
// use in the view
get IntegrationType(): typeof IntegrationType {
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts
index 7a420ade4b5..10ee251a921 100644
--- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts
@@ -7,7 +7,10 @@ import { IntegrationType } from "@bitwarden/common/enums";
name: "filterIntegrations",
})
export class FilterIntegrationsPipe implements PipeTransform {
- transform(integrations: Integration[], type: IntegrationType): Integration[] {
+ transform(integrations: Integration[] | null | undefined, type: IntegrationType): Integration[] {
+ if (!integrations) {
+ return [];
+ }
return integrations.filter((integration) => integration.type === type);
}
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts
index 1667689b186..626fc5dee88 100644
--- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts
@@ -3,16 +3,31 @@ import { RouterModule, Routes } from "@angular/router";
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
+import { DeviceManagementComponent } from "./device-management/device-management.component";
+import { EventManagementComponent } from "./event-management/event-management.component";
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
+import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver";
+import { OrganizationIntegrationsState } from "./organization-integrations.state";
+import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component";
+import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component";
const routes: Routes = [
{
path: "",
canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)],
- component: AdminConsoleIntegrationsComponent,
data: {
titleId: "integrations",
},
+ component: AdminConsoleIntegrationsComponent,
+ providers: [OrganizationIntegrationsState, OrganizationIntegrationsResolver],
+ resolve: { integrations: OrganizationIntegrationsResolver },
+ children: [
+ { path: "", redirectTo: "single-sign-on", pathMatch: "full" },
+ { path: "single-sign-on", component: SingleSignOnComponent },
+ { path: "user-provisioning", component: UserProvisioningComponent },
+ { path: "event-management", component: EventManagementComponent },
+ { path: "device-management", component: DeviceManagementComponent },
+ ],
},
];
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts
index 789ae548521..33f389a92a9 100644
--- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts
@@ -1,17 +1,30 @@
import { NgModule } from "@angular/core";
+import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { safeProvider } from "@bitwarden/ui-common";
+import { EventManagementComponent } from "./event-management/event-management.component";
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module";
+import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver";
+import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component";
+import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component";
@NgModule({
- imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
+ imports: [
+ AdminConsoleIntegrationsComponent,
+ OrganizationIntegrationsRoutingModule,
+ SingleSignOnComponent,
+ UserProvisioningComponent,
+ DeviceManagementComponent,
+ EventManagementComponent,
+ ],
providers: [
+ OrganizationIntegrationsResolver,
safeProvider({
provide: OrganizationIntegrationService,
useClass: OrganizationIntegrationService,
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts
new file mode 100644
index 00000000000..39bd0cc1dcc
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts
@@ -0,0 +1,285 @@
+import { Injectable } from "@angular/core";
+import { ActivatedRouteSnapshot, Resolve } from "@angular/router";
+import { firstValueFrom } from "rxjs";
+import { take, takeWhile } from "rxjs/operators";
+
+import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
+import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
+import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
+import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { IntegrationType } from "@bitwarden/common/enums";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { getById } from "@bitwarden/common/platform/misc";
+
+import { OrganizationIntegrationsState } from "./organization-integrations.state";
+
+@Injectable()
+export class OrganizationIntegrationsResolver implements Resolve {
+ constructor(
+ private organizationService: OrganizationService,
+ private accountService: AccountService,
+ private configService: ConfigService,
+ private organizationIntegrationService: OrganizationIntegrationService,
+ private state: OrganizationIntegrationsState,
+ ) {}
+
+ async resolve(route: ActivatedRouteSnapshot): Promise {
+ const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
+
+ if (!userId) {
+ throw new Error("User ID not found");
+ }
+
+ const orgId = route.paramMap.get("organizationId")!;
+ const org = await firstValueFrom(
+ this.organizationService.organizations$(userId).pipe(getById(orgId), takeWhile(Boolean)),
+ );
+
+ this.state.setOrganization(org);
+
+ await firstValueFrom(this.organizationIntegrationService.setOrganizationId(org.id));
+
+ const integrations: Integration[] = [
+ {
+ name: "AD FS",
+ linkURL: "https://bitwarden.com/help/saml-adfs/",
+ image: "../../../../../../../images/integrations/azure-active-directory.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Auth0",
+ linkURL: "https://bitwarden.com/help/saml-auth0/",
+ image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "AWS",
+ linkURL: "https://bitwarden.com/help/saml-aws/",
+ image: "../../../../../../../images/integrations/aws-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Microsoft Entra ID",
+ linkURL: "https://bitwarden.com/help/saml-azure/",
+ image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Duo",
+ linkURL: "https://bitwarden.com/help/saml-duo/",
+ image: "../../../../../../../images/integrations/logo-duo-color.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Google",
+ linkURL: "https://bitwarden.com/help/saml-google/",
+ image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "JumpCloud",
+ linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
+ image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "KeyCloak",
+ linkURL: "https://bitwarden.com/help/saml-keycloak/",
+ image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Okta",
+ linkURL: "https://bitwarden.com/help/saml-okta/",
+ image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
+ imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "OneLogin",
+ linkURL: "https://bitwarden.com/help/saml-onelogin/",
+ image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "PingFederate",
+ linkURL: "https://bitwarden.com/help/saml-pingfederate/",
+ image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
+ type: IntegrationType.SSO,
+ },
+ {
+ name: "Microsoft Entra ID",
+ linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
+ image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
+ type: IntegrationType.SCIM,
+ },
+ {
+ name: "Okta",
+ linkURL: "https://bitwarden.com/help/okta-scim-integration/",
+ image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
+ imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
+ type: IntegrationType.SCIM,
+ },
+ {
+ name: "OneLogin",
+ linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
+ image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
+ type: IntegrationType.SCIM,
+ },
+ {
+ name: "JumpCloud",
+ linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
+ image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
+ type: IntegrationType.SCIM,
+ },
+ {
+ name: "Ping Identity",
+ linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
+ image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
+ type: IntegrationType.SCIM,
+ },
+ {
+ name: "Active Directory",
+ linkURL: "https://bitwarden.com/help/ldap-directory/",
+ image: "../../../../../../../images/integrations/azure-active-directory.svg",
+ type: IntegrationType.BWDC,
+ },
+ {
+ name: "Microsoft Entra ID",
+ linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
+ image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
+ type: IntegrationType.BWDC,
+ },
+ {
+ name: "Google Workspace",
+ linkURL: "https://bitwarden.com/help/workspace-directory/",
+ image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
+ type: IntegrationType.BWDC,
+ },
+ {
+ name: "Okta",
+ linkURL: "https://bitwarden.com/help/okta-directory/",
+ image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
+ imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
+ type: IntegrationType.BWDC,
+ },
+ {
+ name: "OneLogin",
+ linkURL: "https://bitwarden.com/help/onelogin-directory/",
+ image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
+ imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
+ type: IntegrationType.BWDC,
+ },
+ {
+ name: "Splunk",
+ linkURL: "https://bitwarden.com/help/splunk-siem/",
+ image: "../../../../../../../images/integrations/logo-splunk-black.svg",
+ imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
+ type: IntegrationType.EVENT,
+ },
+ {
+ name: "Microsoft Sentinel",
+ linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
+ image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
+ type: IntegrationType.EVENT,
+ },
+ {
+ name: "Rapid7",
+ linkURL: "https://bitwarden.com/help/rapid7-siem/",
+ image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
+ imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
+ type: IntegrationType.EVENT,
+ },
+ {
+ name: "Elastic",
+ linkURL: "https://bitwarden.com/help/elastic-siem/",
+ image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
+ type: IntegrationType.EVENT,
+ },
+ {
+ name: "Panther",
+ linkURL: "https://bitwarden.com/help/panther-siem/",
+ image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
+ type: IntegrationType.EVENT,
+ },
+ {
+ name: "Sumo Logic",
+ linkURL: "https://bitwarden.com/help/sumo-logic-siem/",
+ image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg",
+ imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg",
+ type: IntegrationType.EVENT,
+ newBadgeExpiration: "2025-12-31",
+ },
+ {
+ name: "Microsoft Intune",
+ linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
+ image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
+ type: IntegrationType.DEVICE,
+ },
+ ];
+
+ const featureEnabled = await firstValueFrom(
+ this.configService.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike),
+ );
+
+ if (featureEnabled) {
+ integrations.push(
+ {
+ name: OrganizationIntegrationServiceName.CrowdStrike,
+ linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
+ image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
+ type: IntegrationType.EVENT,
+ canSetupConnection: true,
+ integrationType: OrganizationIntegrationType.Hec,
+ },
+ {
+ name: OrganizationIntegrationServiceName.Datadog,
+ linkURL: "https://bitwarden.com/help/datadog-siem/",
+ image: "../../../../../../../images/integrations/logo-datadog-color.svg",
+ type: IntegrationType.EVENT,
+ canSetupConnection: true,
+ integrationType: OrganizationIntegrationType.Datadog,
+ },
+ );
+ }
+
+ // Add Huntress SIEM integration (separate feature flag)
+ const huntressFeatureEnabled = await firstValueFrom(
+ this.configService.getFeatureFlag$(FeatureFlag.EventManagementForHuntress),
+ );
+
+ if (huntressFeatureEnabled) {
+ integrations.push({
+ name: OrganizationIntegrationServiceName.Huntress,
+ linkURL: "https://bitwarden.com/help/huntress-siem/",
+ image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
+ type: IntegrationType.EVENT,
+ description: "huntressEventIntegrationDesc",
+ canSetupConnection: true,
+ integrationType: OrganizationIntegrationType.Hec,
+ });
+ }
+
+ const orgIntegrations = await firstValueFrom(
+ this.organizationIntegrationService.integrations$.pipe(take(1)),
+ );
+
+ const merged = integrations.map((i) => ({
+ ...i,
+ organizationIntegration: orgIntegrations.find((o) => o.serviceName === i.name) ?? null,
+ }));
+
+ this.state.setIntegrations(merged);
+
+ return true;
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts
new file mode 100644
index 00000000000..5e7e6a78ba4
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts
@@ -0,0 +1,22 @@
+import { Injectable, signal } from "@angular/core";
+
+import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+
+@Injectable()
+export class OrganizationIntegrationsState {
+ private readonly _integrations = signal([]);
+ private readonly _organization = signal(undefined);
+
+ // Signals
+ integrations = this._integrations.asReadonly();
+ organization = this._organization.asReadonly();
+
+ setOrganization(val: Organization | null) {
+ this._organization.set(val ?? undefined);
+ }
+
+ setIntegrations(val: Integration[]) {
+ this._integrations.set(val);
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html
new file mode 100644
index 00000000000..ca5ed9ee30c
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html
@@ -0,0 +1,12 @@
+@let integrationsList = integrations();
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts
new file mode 100644
index 00000000000..d0d2a1666f2
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts
@@ -0,0 +1,22 @@
+import { Component } from "@angular/core";
+
+import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
+import { FilterIntegrationsPipe } from "../integrations.pipe";
+import { OrganizationIntegrationsState } from "../organization-integrations.state";
+
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ selector: "single-sign-on",
+ templateUrl: "single-sign-on.component.html",
+ imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
+})
+export class SingleSignOnComponent {
+ integrations = this.state.integrations;
+ IntegrationType = IntegrationType;
+
+ constructor(private state: OrganizationIntegrationsState) {}
+}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html
new file mode 100644
index 00000000000..a254f334e21
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html
@@ -0,0 +1,25 @@
+@let org = organization();
+@let integrationsList = integrations();
+
+
+
+ {{ "scimIntegration" | i18n }}
+
+
+ {{ "scimIntegrationDescStart" | i18n }}
+ {{ "scimIntegration" | i18n }}
+ {{ "scimIntegrationDescEnd" | i18n }}
+
+
+
+
+
+ {{ "bwdc" | i18n }}
+
+ {{ "bwdcDesc" | i18n }}
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts
new file mode 100644
index 00000000000..f484674d224
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts
@@ -0,0 +1,26 @@
+import { Component } from "@angular/core";
+
+import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
+import { FilterIntegrationsPipe } from "../integrations.pipe";
+import { OrganizationIntegrationsState } from "../organization-integrations.state";
+
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ selector: "user-provisioning",
+ templateUrl: "user-provisioning.component.html",
+ imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
+})
+export class UserProvisioningComponent {
+ organization = this.state.organization;
+ integrations = this.state.integrations;
+
+ constructor(private state: OrganizationIntegrationsState) {}
+
+ get IntegrationType(): typeof IntegrationType {
+ return IntegrationType;
+ }
+}
diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts
index 74a91897a58..f2b51d6747a 100644
--- a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts
+++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts
@@ -1,11 +1,11 @@
import { EncString } from "@bitwarden/sdk-internal";
export class OrganizationUserBulkRestoreRequest {
- userIds: string[];
+ ids: string[];
defaultUserCollectionName: EncString | undefined;
- constructor(userIds: string[], defaultUserCollectionName?: EncString) {
- this.userIds = userIds;
+ constructor(ids: string[], defaultUserCollectionName?: EncString) {
+ this.ids = ids;
this.defaultUserCollectionName = defaultUserCollectionName;
}
}
diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts
index 0448b23e4d2..7eca35fd36e 100644
--- a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts
+++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts
@@ -258,7 +258,7 @@ describe("DefaultOrganizationUserService", () => {
).toHaveBeenCalledWith(
mockOrganization.id,
expect.objectContaining({
- userIds: mockUserIds,
+ ids: mockUserIds,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
}),
);
diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts
index c4fe2741e11..6058955788e 100644
--- a/libs/angular/src/vault/components/vault-items.component.ts
+++ b/libs/angular/src/vault/components/vault-items.component.ts
@@ -94,7 +94,7 @@ export class VaultItemsComponent implements OnDestroy
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
- private configService: ConfigService,
+ protected configService: ConfigService,
) {
this.subscribeToCiphers();
diff --git a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts
index b16371198b3..645666c582d 100644
--- a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts
+++ b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts
@@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng";
export abstract class CryptoFunctionService {
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract pbkdf2(
@@ -17,7 +17,7 @@ export abstract class CryptoFunctionService {
iterations: number,
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdf(
@@ -28,7 +28,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdfExpand(
@@ -38,7 +38,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hash(
@@ -46,7 +46,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1" | "sha256" | "sha512" | "md5",
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hmacFast(
@@ -56,7 +56,7 @@ export abstract class CryptoFunctionService {
): Promise;
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFastParameters(
@@ -66,7 +66,7 @@ export abstract class CryptoFunctionService {
key: SymmetricCryptoKey,
): CbcDecryptParameters;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFast({
@@ -76,7 +76,7 @@ export abstract class CryptoFunctionService {
| { mode: "cbc"; parameters: CbcDecryptParameters }
| { mode: "ecb"; parameters: EcbDecryptParameters }): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
*/
abstract aesDecrypt(
data: Uint8Array,
@@ -85,7 +85,7 @@ export abstract class CryptoFunctionService {
mode: "cbc" | "ecb",
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaEncrypt(
@@ -94,7 +94,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1",
): Promise;
/**
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaDecrypt(
diff --git a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts
index ddc3829aa9f..1114e892bb8 100644
--- a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts
+++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts
@@ -27,7 +27,7 @@ export abstract class KeyGenerationService {
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
* for details.
*
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param bitLength Length of key material.
@@ -44,7 +44,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 64 byte key from key material.
*
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
@@ -63,7 +63,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 32 byte key from a password using a key derivation function.
*
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param password Password to derive the key from.
@@ -80,7 +80,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 64 byte key from a 32 byte key using a key derivation function.
*
- * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
+ * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param key 32 byte key.
diff --git a/libs/common/src/tools/providers.spec.ts b/libs/common/src/tools/providers.spec.ts
index 5953e5ebab2..d457b1df85e 100644
--- a/libs/common/src/tools/providers.spec.ts
+++ b/libs/common/src/tools/providers.spec.ts
@@ -4,6 +4,7 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi
import { ConfigService } from "../platform/abstractions/config/config.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
+import { SdkService } from "../platform/abstractions/sdk/sdk.service";
import { StateProvider } from "../platform/state";
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
@@ -20,6 +21,7 @@ describe("SystemServiceProvider", () => {
let mockLogger: LogService;
let mockEnvironment: MockProxy;
let mockConfigService: ConfigService;
+ let mockSdkService: SdkService;
beforeEach(() => {
jest.resetAllMocks();
@@ -31,6 +33,7 @@ describe("SystemServiceProvider", () => {
mockLogger = mock();
mockEnvironment = mock();
mockConfigService = mock();
+ mockSdkService = mock();
});
describe("createSystemServiceProvider", () => {
@@ -45,6 +48,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result).toHaveProperty("policy", mockPolicy);
@@ -66,6 +70,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result.extension).toBeInstanceOf(ExtensionService);
@@ -83,6 +88,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
@@ -102,6 +108,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
@@ -121,6 +128,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result.extension).toBeInstanceOf(ExtensionService);
@@ -138,6 +146,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result.policy).toBe(mockPolicy);
@@ -154,6 +163,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result.configService).toBe(mockConfigService);
@@ -170,6 +180,7 @@ describe("SystemServiceProvider", () => {
mockLogger,
mockEnvironment,
mockConfigService,
+ mockSdkService,
);
expect(result.environment).toBe(mockEnvironment);
diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts
index ac42c556042..b1621f19c21 100644
--- a/libs/common/src/tools/providers.ts
+++ b/libs/common/src/tools/providers.ts
@@ -1,10 +1,10 @@
import { LogService } from "@bitwarden/logging";
-import { BitwardenClient } from "@bitwarden/sdk-internal";
import { StateProvider } from "@bitwarden/state";
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
import { ConfigService } from "../platform/abstractions/config/config.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
+import { SdkService } from "../platform/abstractions/sdk/sdk.service";
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
import { ExtensionRegistry } from "./extension/extension-registry.abstraction";
@@ -29,7 +29,7 @@ export type SystemServiceProvider = {
readonly environment: PlatformUtilsService;
/** SDK Service */
- readonly sdk?: BitwardenClient;
+ readonly sdk: SdkService;
};
/** Constructs a system service provider. */
@@ -41,6 +41,7 @@ export function createSystemServiceProvider(
logger: LogService,
environment: PlatformUtilsService,
configService: ConfigService,
+ sdk: SdkService,
): SystemServiceProvider {
let log: LogProvider;
if (environment.isDev()) {
@@ -62,5 +63,6 @@ export function createSystemServiceProvider(
log,
configService,
environment,
+ sdk,
};
}
diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html
index 26e0c3b4d3d..d8718340217 100644
--- a/libs/components/src/button/button.component.html
+++ b/libs/components/src/button/button.component.html
@@ -1,6 +1,14 @@
-
-
-
+
+
+ @if (startIcon()) {
+
+ }
+
+
+
+ @if (endIcon()) {
+
+ }
@if (showLoadingStyle()) {
diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts
index 7cae8fe974d..1055d134e53 100644
--- a/libs/components/src/button/button.component.ts
+++ b/libs/components/src/button/button.component.ts
@@ -1,4 +1,4 @@
-import { NgClass } from "@angular/common";
+import { NgClass, NgTemplateOutlet } from "@angular/common";
import {
input,
HostBinding,
@@ -14,6 +14,7 @@ import { debounce, interval } from "rxjs";
import { AriaDisableDirective } from "../a11y";
import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction";
+import { BitwardenIcon } from "../shared/icon";
import { SpinnerComponent } from "../spinner";
import { ariaDisableElement } from "../utils";
@@ -71,7 +72,7 @@ const buttonStyles: Record = {
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
- imports: [NgClass, SpinnerComponent],
+ imports: [NgClass, NgTemplateOutlet, SpinnerComponent],
hostDirectives: [AriaDisableDirective],
})
export class ButtonComponent implements ButtonLikeAbstraction {
@@ -125,12 +126,23 @@ export class ButtonComponent implements ButtonLikeAbstraction {
readonly buttonType = input("secondary");
+ readonly startIcon = input(undefined);
+
+ readonly endIcon = input(undefined);
+
readonly size = input("default");
readonly block = input(false, { transform: booleanAttribute });
readonly loading = model(false);
+ readonly startIconClasses = computed(() => {
+ return ["bwi", this.startIcon()];
+ });
+
+ readonly endIconClasses = computed(() => {
+ return ["bwi", this.endIcon()];
+ });
/**
* Determine whether it is appropriate to display a loading spinner. We only want to show
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents
diff --git a/libs/components/src/button/button.stories.ts b/libs/components/src/button/button.stories.ts
index 24c263f240a..9e8d23611ff 100644
--- a/libs/components/src/button/button.stories.ts
+++ b/libs/components/src/button/button.stories.ts
@@ -152,15 +152,13 @@ export const WithIcon: Story = {
template: /*html*/ `
-
-
+
Button label
-
diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts
index ff1a8c16d5f..fb1a2d67a40 100644
--- a/libs/components/src/callout/callout.stories.ts
+++ b/libs/components/src/callout/callout.stories.ts
@@ -113,7 +113,7 @@ export const WithTextButton: Story = {
template: `
(args)}>
The content of the callout
- Visit the help center
+ Visit the help center
`,
}),
diff --git a/libs/components/src/card/base-card/base-card.stories.ts b/libs/components/src/card/base-card/base-card.stories.ts
index bae07dd1468..98814c1f9f4 100644
--- a/libs/components/src/card/base-card/base-card.stories.ts
+++ b/libs/components/src/card/base-card/base-card.stories.ts
@@ -1,6 +1,6 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
-import { AnchorLinkDirective } from "../../link";
+import { LinkComponent } from "../../link";
import { TypographyModule } from "../../typography";
import { BaseCardComponent } from "./base-card.component";
@@ -10,7 +10,7 @@ export default {
component: BaseCardComponent,
decorators: [
moduleMetadata({
- imports: [AnchorLinkDirective, TypographyModule],
+ imports: [LinkComponent, TypographyModule],
}),
],
parameters: {
diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts
index 2ce19a9f9e0..63fbb69399d 100644
--- a/libs/components/src/dialog/dialog/dialog.component.ts
+++ b/libs/components/src/dialog/dialog/dialog.component.ts
@@ -12,9 +12,10 @@ import {
computed,
signal,
AfterViewInit,
+ NgZone,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
-import { combineLatest, switchMap } from "rxjs";
+import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -65,6 +66,9 @@ const drawerSizeToWidth = {
})
export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
+ private readonly ngZone = inject(NgZone);
+ private readonly el = inject(ElementRef);
+
private readonly dialogHeader =
viewChild.required>("dialogHeader");
private readonly scrollableBody = viewChild.required(CdkScrollable);
@@ -144,10 +148,6 @@ export class DialogComponent implements AfterViewInit {
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
});
- ngAfterViewInit() {
- this.focusOnHeader();
- }
-
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
@@ -159,24 +159,54 @@ export class DialogComponent implements AfterViewInit {
this.animationCompleted.set(true);
}
- /**
- * Moves focus to the dialog header element.
- * This is done automatically when the dialog is opened but can be called manually
- * when the contents of the dialog change and focus should be reset.
- */
- focusOnHeader(): void {
+ async ngAfterViewInit() {
/**
- * Wait a tick for any focus management to occur on the trigger element before moving focus to
- * the dialog header. We choose the dialog header because it is always present, unlike possible
- * interactive elements.
- *
- * We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
- * because we need this delay behavior.
+ * Wait for the zone to stabilize before performing any focus behaviors. This ensures that all
+ * child elements are rendered and stable.
*/
- const headerFocusTimeout = setTimeout(() => {
- this.dialogHeader().nativeElement.focus();
- }, 0);
+ if (this.ngZone.isStable) {
+ this.handleAutofocus();
+ } else {
+ await firstValueFrom(this.ngZone.onStable);
+ this.handleAutofocus();
+ }
+ }
- this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
+ /**
+ * Ensure that the user's focus is in the dialog by autofocusing the appropriate element.
+ *
+ * If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that.
+ * If not, we want to fallback to a default behavior of focusing the dialog's header element. We
+ * choose the dialog header as the default fallback for dialog focus because it is always present,
+ * unlike possible interactive elements.
+ */
+ handleAutofocus() {
+ /**
+ * Angular's contentChildren query cannot see into the internal templates of child components.
+ * We need to use a regular DOM query instead to see if there are descendants using the
+ * AutofocusDirective.
+ */
+ const dialogRef = this.el.nativeElement;
+ // Must match selectors of AutofocusDirective
+ const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
+ const hasAutofocusDescendants = autofocusDescendants.length > 0;
+
+ if (!hasAutofocusDescendants) {
+ /**
+ * Wait a tick for any focus management to occur on the trigger element before moving focus
+ * to the dialog header.
+ *
+ * We are doing this manually instead of using Angular's built-in focus management
+ * directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay
+ * behavior.
+ *
+ * And yes, we need the timeout even though we are already waiting for ngZone to stabilize.
+ */
+ const headerFocusTimeout = setTimeout(() => {
+ this.dialogHeader().nativeElement.focus();
+ }, 0);
+
+ this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
+ }
}
}
diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts
index a4791a51f01..bffac8eb757 100644
--- a/libs/components/src/input/autofocus.directive.ts
+++ b/libs/components/src/input/autofocus.directive.ts
@@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element";
*
* If the component provides the `FocusableElement` interface, the `focus`
* method will be called. Otherwise, the native element will be focused.
+ *
+ * If selector changes, `dialog.component.ts` must also be updated
*/
@Directive({
selector: "[appAutofocus], [bitAutofocus]",
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts
index da30b76a9f0..c71c4e73c6e 100644
--- a/libs/components/src/layout/layout.component.ts
+++ b/libs/components/src/layout/layout.component.ts
@@ -5,7 +5,7 @@ import { booleanAttribute, Component, ElementRef, inject, input, viewChild } fro
import { RouterModule } from "@angular/router";
import { DrawerService } from "../dialog/drawer.service";
-import { LinkModule } from "../link";
+import { LinkComponent, LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -52,11 +52,11 @@ export class LayoutComponent {
*
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
**/
- private readonly skipLink = viewChild.required>("skipLink");
+ private readonly skipLink = viewChild.required("skipLink");
handleKeydown(ev: KeyboardEvent) {
if (isNothingFocused()) {
ev.preventDefault();
- this.skipLink().nativeElement.focus();
+ this.skipLink().el.nativeElement.focus();
}
}
}
diff --git a/libs/components/src/link/index.ts b/libs/components/src/link/index.ts
index 480f5396de7..08617e813f5 100644
--- a/libs/components/src/link/index.ts
+++ b/libs/components/src/link/index.ts
@@ -1,2 +1,2 @@
-export * from "./link.directive";
+export * from "./link.component";
export * from "./link.module";
diff --git a/libs/components/src/link/link.component.html b/libs/components/src/link/link.component.html
new file mode 100644
index 00000000000..810b65db519
--- /dev/null
+++ b/libs/components/src/link/link.component.html
@@ -0,0 +1,11 @@
+
+ @if (startIcon()) {
+
+ }
+
+
+
+ @if (endIcon()) {
+
+ }
+
diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.component.ts
similarity index 59%
rename from libs/components/src/link/link.directive.ts
rename to libs/components/src/link/link.component.ts
index 62f0d8b878f..d826a4633a9 100644
--- a/libs/components/src/link/link.directive.ts
+++ b/libs/components/src/link/link.component.ts
@@ -1,6 +1,14 @@
-import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core";
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ booleanAttribute,
+ inject,
+ ElementRef,
+} from "@angular/core";
-import { AriaDisableDirective } from "../a11y";
+import { BitwardenIcon } from "../shared/icon";
import { ariaDisableElement } from "../utils";
export const LinkTypes = [
@@ -46,16 +54,16 @@ const commonStyles = [
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
- "hover:tw-underline",
- "hover:tw-decoration-1",
+ "[&:hover_span]:tw-underline",
+ "[&.tw-test-hover_span]:tw-underline",
+ "[&:hover_span]:tw-decoration-[.125em]",
+ "[&.tw-test-hover_span]:tw-decoration-[.125em]",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
- "focus-visible:tw-underline",
- "focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
@@ -72,8 +80,12 @@ const commonStyles = [
"before:tw-block",
"before:tw-absolute",
"before:-tw-inset-x-[0.1em]",
+ "before:-tw-inset-y-[0]",
"before:tw-rounded-md",
"before:tw-transition",
+ "before:tw-h-full",
+ "before:tw-w-[calc(100%_+_.25rem)]",
+ "before:tw-pointer-events-none",
"focus-visible:before:tw-ring-2",
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
@@ -83,47 +95,57 @@ const commonStyles = [
"aria-disabled:hover:tw-no-underline",
];
-@Directive()
-abstract class LinkDirective {
- readonly linkType = input("default");
-}
-
-/**
- * Text Links and Buttons can use either the `` or `` tags. Choose which based on the action the button takes:
-
- * - if navigating to a new page, use a ``
- * - if taking an action on the current page, use a ``
-
- * Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions or show/hide additional form options.
- */
-@Directive({
- selector: "a[bitLink]",
+@Component({
+ selector: "a[bitLink], button[bitLink]",
+ templateUrl: "./link.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ "[class]": "classList()",
+ // This is for us to be able to correctly aria-disable the button and capture clicks.
+ // It's normally added via the AriaDisableDirective as a host directive.
+ // But, we're not able to conditionally apply the host directive based on if this is a button or not
+ "[attr.bit-aria-disable]": "isButton ? true : null",
+ },
})
-export class AnchorLinkDirective extends LinkDirective {
- @HostBinding("class") get classList() {
- return ["before:-tw-inset-y-[0.125rem]"]
- .concat(commonStyles)
- .concat(linkStyles[this.linkType()] ?? []);
- }
-}
-
-@Directive({
- selector: "button[bitLink]",
- hostDirectives: [AriaDisableDirective],
-})
-export class ButtonLinkDirective extends LinkDirective {
- private el = inject(ElementRef);
-
+export class LinkComponent {
+ readonly el = inject(ElementRef);
+ /**
+ * The variant of link you want to render
+ * @default "primary"
+ */
+ readonly linkType = input("primary");
+ /**
+ * The leading icon to display within the link
+ * @default undefined
+ */
+ readonly startIcon = input(undefined);
+ /**
+ * The trailing icon to display within the link
+ * @default undefined
+ */
+ readonly endIcon = input(undefined);
+ /**
+ * Whether the button is disabled
+ * @default false
+ * @note Only applicable if the link is rendered as a button
+ */
readonly disabled = input(false, { transform: booleanAttribute });
- @HostBinding("class") get classList() {
- return ["before:-tw-inset-y-[0.25rem]"]
+ protected readonly isButton = this.el.nativeElement.tagName === "BUTTON";
+
+ readonly classList = computed(() => {
+ return [!this.isButton && "tw-inline-flex"]
.concat(commonStyles)
.concat(linkStyles[this.linkType()] ?? []);
+ });
+
+ focus() {
+ this.el.nativeElement.focus();
}
constructor() {
- super();
- ariaDisableElement(this.el.nativeElement, this.disabled);
+ if (this.isButton) {
+ ariaDisableElement(this.el.nativeElement, this.disabled);
+ }
}
}
diff --git a/libs/components/src/link/link.module.ts b/libs/components/src/link/link.module.ts
index 52d2f29e53c..87ad8daa7e1 100644
--- a/libs/components/src/link/link.module.ts
+++ b/libs/components/src/link/link.module.ts
@@ -1,9 +1,9 @@
import { NgModule } from "@angular/core";
-import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
+import { LinkComponent } from "./link.component";
@NgModule({
- imports: [AnchorLinkDirective, ButtonLinkDirective],
- exports: [AnchorLinkDirective, ButtonLinkDirective],
+ imports: [LinkComponent],
+ exports: [LinkComponent],
})
export class LinkModule {}
diff --git a/libs/components/src/link/link.stories.ts b/libs/components/src/link/link.stories.ts
index d27c4f74332..6df3a11ce40 100644
--- a/libs/components/src/link/link.stories.ts
+++ b/libs/components/src/link/link.stories.ts
@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
-import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
+import { LinkComponent, LinkTypes } from "./link.component";
import { LinkModule } from "./link.module";
export default {
@@ -26,7 +26,7 @@ export default {
},
} as Meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const Default: Story = {
render: (args) => ({
@@ -40,9 +40,9 @@ export const Default: Story = {
: "tw-bg-transparent",
},
template: /*html*/ `
-
+
`,
}),
args: {
@@ -181,14 +181,12 @@ export const Buttons: Story = {
Button