diff --git a/apps/web/src/app/admin-console/icons/devices.ts b/apps/web/src/app/admin-console/icons/devices.ts new file mode 100644 index 00000000000..348c836c4b7 --- /dev/null +++ b/apps/web/src/app/admin-console/icons/devices.ts @@ -0,0 +1,17 @@ +import { svgIcon } from "@bitwarden/components"; + +export const Devices = svgIcon` + + + + + + + + + + + + + +`; diff --git a/apps/web/src/app/admin-console/icons/index.ts b/apps/web/src/app/admin-console/icons/index.ts new file mode 100644 index 00000000000..e0c2c124af1 --- /dev/null +++ b/apps/web/src/app/admin-console/icons/index.ts @@ -0,0 +1 @@ +export * from "./devices"; diff --git a/apps/web/src/app/admin-console/organizations/settings/settings.component.html b/apps/web/src/app/admin-console/organizations/settings/settings.component.html index 146a7203430..bc2b2e54a0c 100644 --- a/apps/web/src/app/admin-console/organizations/settings/settings.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/settings.component.html @@ -60,6 +60,16 @@ > {{ "singleSignOn" | i18n }} + + + {{ "deviceApprovals" | i18n }} + + ; + FeatureFlag = FeatureFlag; constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9224bfe8d11..b9124dca169 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6844,5 +6844,41 @@ }, "updatedTempPassword": { "message": "User updated a password issued through account recovery." + }, + "deviceApprovals": { + "message": "Device approvals" + }, + "deviceApprovalsDesc": { + "message": "Approve login requests below to allow the requesting member to finish logging in. Unapproved requests expire after 1 week. Verify the member’s information before approving." + }, + "deviceInfo": { + "message": "Device info" + }, + "time": { + "message": "Time" + }, + "denyAllRequests": { + "message": "Deny all requests" + }, + "denyRequest": { + "message": "Deny request" + }, + "approveRequest": { + "message": "Approve request" + }, + "noDeviceRequests": { + "message": "No device requests" + }, + "noDeviceRequestsDesc": { + "message": "Member device approval requests will appear here" + }, + "loginRequestDenied": { + "message": "Login request denied" + }, + "allLoginRequestsDenied": { + "message": "All login requests denied" + }, + "loginRequestApproved": { + "message": "Login request approved" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/core-organization.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/core-organization.module.ts new file mode 100644 index 00000000000..bba3bfce930 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/core-organization.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from "@angular/core"; + +import { OrganizationAuthRequestService } from "./services/auth-requests"; + +@NgModule({ + providers: [OrganizationAuthRequestService], +}) +export class CoreOrganizationModule {} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/index.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/index.ts new file mode 100644 index 00000000000..4d758be8c6b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/index.ts @@ -0,0 +1 @@ +export * from "./core-organization.module"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/admin-auth-request-update.request.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/admin-auth-request-update.request.ts new file mode 100644 index 00000000000..a190d5809ce --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/admin-auth-request-update.request.ts @@ -0,0 +1,8 @@ +export class AdminAuthRequestUpdateRequest { + /** + * + * @param requestApproved - Whether the request was approved/denied. If true, the key must be provided. + * @param encryptedUserKey The user's symmetric key that has been encrypted with a device public key if the request was approved. + */ + constructor(public requestApproved: boolean, public encryptedUserKey?: string) {} +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/bulk-deny-auth-requests.request.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/bulk-deny-auth-requests.request.ts new file mode 100644 index 00000000000..09054c4b589 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/bulk-deny-auth-requests.request.ts @@ -0,0 +1,6 @@ +export class BulkDenyAuthRequestsRequest { + private ids: string[]; + constructor(authRequestIds: string[]) { + this.ids = authRequestIds; + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/index.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/index.ts new file mode 100644 index 00000000000..d8c4bacd697 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/index.ts @@ -0,0 +1,2 @@ +export * from "./pending-organization-auth-request.response"; +export * from "./organization-auth-request.service"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/organization-auth-request.service.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/organization-auth-request.service.ts new file mode 100644 index 00000000000..77e4cba0333 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/organization-auth-request.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import { PendingAuthRequestView } from "../../views/pending-auth-request.view"; + +import { AdminAuthRequestUpdateRequest } from "./admin-auth-request-update.request"; +import { BulkDenyAuthRequestsRequest } from "./bulk-deny-auth-requests.request"; +import { PendingOrganizationAuthRequestResponse } from "./pending-organization-auth-request.response"; + +@Injectable() +export class OrganizationAuthRequestService { + constructor(private apiService: ApiService) {} + + async listPendingRequests(organizationId: string): Promise { + const r = await this.apiService.send( + "GET", + `/organizations/${organizationId}/auth-requests`, + null, + true, + true + ); + + const listResponse = new ListResponse(r, PendingOrganizationAuthRequestResponse); + + return listResponse.data.map((ar) => PendingAuthRequestView.fromResponse(ar)); + } + + async denyPendingRequests(organizationId: string, ...requestIds: string[]): Promise { + await this.apiService.send( + "POST", + `/organizations/${organizationId}/auth-requests/deny`, + new BulkDenyAuthRequestsRequest(requestIds), + true, + false + ); + } + + async approvePendingRequest( + organizationId: string, + requestId: string, + encryptedKey: EncString + ): Promise { + await this.apiService.send( + "POST", + `/organizations/${organizationId}/auth-requests/${requestId}`, + new AdminAuthRequestUpdateRequest(true, encryptedKey.encryptedString), + true, + false + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/pending-organization-auth-request.response.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/pending-organization-auth-request.response.ts new file mode 100644 index 00000000000..b4854eea4aa --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/pending-organization-auth-request.response.ts @@ -0,0 +1,26 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class PendingOrganizationAuthRequestResponse extends BaseResponse { + id: string; + userId: string; + organizationUserId: string; + email: string; + publicKey: string; + requestDeviceIdentifier: string; + requestDeviceType: string; + requestIpAddress: string; + creationDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.userId = this.getResponseProperty("UserId"); + this.organizationUserId = this.getResponseProperty("OrganizationUserId"); + this.email = this.getResponseProperty("Email"); + this.publicKey = this.getResponseProperty("PublicKey"); + this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier"); + this.requestDeviceType = this.getResponseProperty("RequestDeviceType"); + this.requestIpAddress = this.getResponseProperty("RequestIpAddress"); + this.creationDate = this.getResponseProperty("CreationDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/core/views/pending-auth-request.view.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/views/pending-auth-request.view.ts new file mode 100644 index 00000000000..8f3415a236b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/core/views/pending-auth-request.view.ts @@ -0,0 +1,23 @@ +import { View } from "@bitwarden/common/models/view/view"; + +import { PendingOrganizationAuthRequestResponse } from "../services/auth-requests"; + +export class PendingAuthRequestView implements View { + id: string; + userId: string; + organizationUserId: string; + email: string; + publicKey: string; + requestDeviceIdentifier: string; + requestDeviceType: string; + requestIpAddress: string; + creationDate: Date; + + static fromResponse(response: PendingOrganizationAuthRequestResponse): PendingAuthRequestView { + const view = Object.assign(new PendingAuthRequestView(), response) as PendingAuthRequestView; + + view.creationDate = new Date(response.creationDate); + + return view; + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html new file mode 100644 index 00000000000..4758cc47ce0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html @@ -0,0 +1,100 @@ +

+ {{ "deviceApprovals" | i18n }} + + {{ "loading" | i18n }} +

+

+ {{ "deviceApprovalsDesc" | i18n }} +

+ + + + + {{ "member" | i18n }} + {{ "deviceInfo" | i18n }} + {{ "time" | i18n }} + + + + + + + + + + + +
{{ r.email }}
+ {{ r.publicKey | fingerprint : r.email | async }} + + +
{{ r.requestDeviceType }}
+
{{ r.requestIpAddress }}
+ + + {{ r.creationDate | date : "medium" }} + + + + + + + + + +
+
+ + + {{ "noDeviceRequests" | i18n }} + {{ "noDeviceRequestsDesc" | i18n }} + diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts new file mode 100644 index 00000000000..6325a72f80d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts @@ -0,0 +1,174 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { BehaviorSubject, Subject, switchMap, takeUntil, tap } from "rxjs"; + +import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; +import { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { TableDataSource } from "@bitwarden/components"; +import { Devices } from "@bitwarden/web-vault/app/admin-console/icons"; + +import { OrganizationAuthRequestService } from "../../core/services/auth-requests"; +import { PendingAuthRequestView } from "../../core/views/pending-auth-request.view"; + +@Component({ + selector: "app-org-device-approvals", + templateUrl: "./device-approvals.component.html", +}) +export class DeviceApprovalsComponent implements OnInit, OnDestroy { + tableDataSource = new TableDataSource(); + organizationId: string; + loading = true; + actionInProgress = false; + + protected readonly Devices = Devices; + + private destroy$ = new Subject(); + private refresh$ = new BehaviorSubject(null); + + constructor( + private organizationAuthRequestService: OrganizationAuthRequestService, + private organizationUserService: OrganizationUserService, + private cryptoService: CryptoService, + private route: ActivatedRoute, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService, + private validationService: ValidationService + ) {} + + async ngOnInit() { + this.route.params + .pipe( + tap((params) => (this.organizationId = params.organizationId)), + switchMap(() => + this.refresh$.pipe( + tap(() => (this.loading = true)), + switchMap(() => + this.organizationAuthRequestService.listPendingRequests(this.organizationId) + ) + ) + ), + takeUntil(this.destroy$) + ) + .subscribe((r) => { + this.tableDataSource.data = r; + this.loading = false; + }); + } + + /** + * Creates a copy of the user's symmetric key that has been encrypted with the provided device's public key. + * @param devicePublicKey + * @param resetPasswordDetails + * @private + */ + private async getEncryptedUserSymKey( + devicePublicKey: string, + resetPasswordDetails: OrganizationUserResetPasswordDetailsResponse + ): Promise { + const encryptedUserSymKey = resetPasswordDetails.resetPasswordKey; + const encryptedOrgPrivateKey = resetPasswordDetails.encryptedPrivateKey; + const devicePubKey = Utils.fromB64ToArray(devicePublicKey); + + // Decrypt Organization's encrypted Private Key with org key + const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId); + const decOrgPrivateKey = await this.cryptoService.decryptToBytes( + new EncString(encryptedOrgPrivateKey), + orgSymKey + ); + + // Decrypt User's symmetric key with decrypted org private key + const decValue = await this.cryptoService.rsaDecrypt(encryptedUserSymKey, decOrgPrivateKey); + const userSymKey = new SymmetricCryptoKey(decValue); + + // Re-encrypt User's Symmetric Key with the Device Public Key + return await this.cryptoService.rsaEncrypt(userSymKey.key, devicePubKey.buffer); + } + + async approveRequest(authRequest: PendingAuthRequestView) { + await this.performAsyncAction(async () => { + const details = await this.organizationUserService.getOrganizationUserResetPasswordDetails( + this.organizationId, + authRequest.organizationUserId + ); + + // The user must be enrolled in account recovery (password reset) in order for the request to be approved. + if (details == null || details.resetPasswordKey == null) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("resetPasswordDetailsError") + ); + return; + } + + const encryptedKey = await this.getEncryptedUserSymKey(authRequest.publicKey, details); + + await this.organizationAuthRequestService.approvePendingRequest( + this.organizationId, + authRequest.id, + encryptedKey + ); + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("loginRequestApproved") + ); + }); + } + + async denyRequest(requestId: string) { + await this.performAsyncAction(async () => { + await this.organizationAuthRequestService.denyPendingRequests(this.organizationId, requestId); + this.platformUtilsService.showToast("error", null, this.i18nService.t("loginRequestDenied")); + }); + } + + async denyAllRequests() { + if (this.tableDataSource.data.length === 0) { + return; + } + + await this.performAsyncAction(async () => { + await this.organizationAuthRequestService.denyPendingRequests( + this.organizationId, + ...this.tableDataSource.data.map((r) => r.id) + ); + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("allLoginRequestsDenied") + ); + }); + } + + private async performAsyncAction(action: () => Promise) { + if (this.actionInProgress) { + return; + } + this.actionInProgress = true; + try { + await action(); + this.refresh$.next(); + } catch (err: unknown) { + this.logService.error(err.toString()); + this.validationService.showError(err); + } finally { + this.actionInProgress = false; + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts index d1f71325ce9..22cd2571cae 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts @@ -2,14 +2,17 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; +import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component"; import { SettingsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/settings/settings.component"; import { SsoComponent } from "../../auth/sso/sso.component"; +import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component"; import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component"; import { ScimComponent } from "./manage/scim.component"; @@ -51,6 +54,18 @@ const routes: Routes = [ organizationPermissions: (org: Organization) => org.canManageScim, }, }, + { + path: "device-approvals", + component: DeviceApprovalsComponent, + canActivate: [ + OrganizationPermissionsGuard, + canAccessFeature(FeatureFlag.TrustedDeviceEncryption), + ], + data: { + organizationPermissions: (org: Organization) => org.canManageUsersPassword, + titleId: "deviceApprovals", + }, + }, ], }, ], diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations.module.ts index 08f7dea6406..3e939fa74f5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations.module.ts @@ -1,21 +1,25 @@ import { NgModule } from "@angular/core"; +import { NoItemsModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; import { SsoComponent } from "../../auth/sso/sso.component"; +import { CoreOrganizationModule } from "./core"; +import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component"; import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component"; import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component"; import { ScimComponent } from "./manage/scim.component"; import { OrganizationsRoutingModule } from "./organizations-routing.module"; @NgModule({ - imports: [SharedModule, OrganizationsRoutingModule], + imports: [SharedModule, CoreOrganizationModule, OrganizationsRoutingModule, NoItemsModule], declarations: [ SsoComponent, ScimComponent, DomainVerificationComponent, DomainAddEditDialogComponent, + DeviceApprovalsComponent, ], }) export class OrganizationsModule {} diff --git a/libs/angular/src/directives/if-feature.directive.spec.ts b/libs/angular/src/directives/if-feature.directive.spec.ts new file mode 100644 index 00000000000..bf73a172a55 --- /dev/null +++ b/libs/angular/src/directives/if-feature.directive.spec.ts @@ -0,0 +1,137 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { IfFeatureDirective } from "./if-feature.directive"; + +const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag; +const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag; +const testStringFeatureValue = "test-value"; + +@Component({ + template: ` +
+
Hidden behind feature flag
+
+
+
Hidden behind feature flag
+
+
+
+ Hidden behind missing flag. Should not be visible. +
+
+ `, +}) +class TestComponent { + testBooleanFeature = testBooleanFeature; + stringFeature = testStringFeature; + stringFeatureValue = testStringFeatureValue; + + missingFlag = "missing-flag" as FeatureFlag; +} + +describe("IfFeatureDirective", () => { + let fixture: ComponentFixture; + let content: HTMLElement; + let mockConfigService: MockProxy; + + const mockConfigFlagValue = (flag: FeatureFlag, flagValue: any) => { + if (typeof flagValue === "boolean") { + mockConfigService.getFeatureFlagBool.mockImplementation((f, defaultValue = false) => + flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } else if (typeof flagValue === "string") { + mockConfigService.getFeatureFlagString.mockImplementation((f, defaultValue = "") => + flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } else if (typeof flagValue === "number") { + mockConfigService.getFeatureFlagNumber.mockImplementation((f, defaultValue = 0) => + flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } + }; + const queryContent = (testId: string) => + fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement; + + beforeEach(async () => { + mockConfigService = mock(); + + await TestBed.configureTestingModule({ + declarations: [IfFeatureDirective, TestComponent], + providers: [ + { provide: LogService, useValue: mock() }, + { + provide: ConfigServiceAbstraction, + useValue: mockConfigService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + }); + + it("renders content when the feature flag is enabled", async () => { + mockConfigFlagValue(testBooleanFeature, true); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeDefined(); + }); + + it("renders content when the feature flag value matches the provided value", async () => { + mockConfigFlagValue(testStringFeature, testStringFeatureValue); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("string-content"); + + expect(content).toBeDefined(); + }); + + it("hides content when the feature flag is disabled", async () => { + mockConfigFlagValue(testBooleanFeature, false); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the feature flag value does not match the provided value", async () => { + mockConfigFlagValue(testStringFeature, "wrong-value"); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("string-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the feature flag is missing", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("missing-flag-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the directive throws an unexpected exception", async () => { + mockConfigService.getFeatureFlagBool.mockImplementation(() => Promise.reject("Some error")); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeUndefined(); + }); +}); diff --git a/libs/angular/src/directives/if-feature.directive.ts b/libs/angular/src/directives/if-feature.directive.ts new file mode 100644 index 00000000000..1a0ee35dc68 --- /dev/null +++ b/libs/angular/src/directives/if-feature.directive.ts @@ -0,0 +1,67 @@ +import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +// Replace this with a type safe lookup of the feature flag values in PM-2282 +type FlagValue = boolean | number | string; + +/** + * Directive that conditionally renders the element when the feature flag is enabled and/or + * matches the value specified by {@link appIfFeatureValue}. + * + * When a feature flag is not found in the config service, the element is hidden. + */ +@Directive({ + selector: "[appIfFeature]", +}) +export class IfFeatureDirective implements OnInit { + /** + * The feature flag to check. + */ + @Input() appIfFeature: FeatureFlag; + + /** + * Optional value to compare against the value of the feature flag in the config service. + * @default true + */ + @Input() appIfFeatureValue: FlagValue = true; + + private hasView = false; + + constructor( + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef, + private configService: ConfigServiceAbstraction, + private logService: LogService + ) {} + + async ngOnInit() { + try { + let flagValue: FlagValue; + + if (typeof this.appIfFeatureValue === "boolean") { + flagValue = await this.configService.getFeatureFlagBool(this.appIfFeature); + } else if (typeof this.appIfFeatureValue === "number") { + flagValue = await this.configService.getFeatureFlagNumber(this.appIfFeature); + } else if (typeof this.appIfFeatureValue === "string") { + flagValue = await this.configService.getFeatureFlagString(this.appIfFeature); + } + + if (this.appIfFeatureValue === flagValue) { + if (!this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } + } else { + this.viewContainer.clear(); + this.hasView = false; + } + } catch (e) { + this.logService.error(e); + this.viewContainer.clear(); + this.hasView = false; + } + } +} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index bfe8f758f92..929875bbb20 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -11,6 +11,7 @@ import { AutofocusDirective } from "./directives/autofocus.directive"; import { BoxRowDirective } from "./directives/box-row.directive"; import { CopyClickDirective } from "./directives/copy-click.directive"; import { FallbackSrcDirective } from "./directives/fallback-src.directive"; +import { IfFeatureDirective } from "./directives/if-feature.directive"; import { InputStripSpacesDirective } from "./directives/input-strip-spaces.directive"; import { InputVerbatimDirective } from "./directives/input-verbatim.directive"; import { LaunchClickDirective } from "./directives/launch-click.directive"; @@ -25,6 +26,7 @@ import { SearchPipe } from "./pipes/search.pipe"; import { UserNamePipe } from "./pipes/user-name.pipe"; import { UserTypePipe } from "./pipes/user-type.pipe"; import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe"; +import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe"; import { I18nPipe } from "./platform/pipes/i18n.pipe"; import { PasswordStrengthComponent } from "./shared/components/password-strength/password-strength.component"; import { ExportScopeCalloutComponent } from "./tools/export/components/export-scope-callout.component"; @@ -68,6 +70,8 @@ import { IconComponent } from "./vault/components/icon.component"; UserNamePipe, PasswordStrengthComponent, UserTypePipe, + IfFeatureDirective, + FingerprintPipe, ], exports: [ A11yInvalidDirective, @@ -97,7 +101,17 @@ import { IconComponent } from "./vault/components/icon.component"; UserNamePipe, PasswordStrengthComponent, UserTypePipe, + IfFeatureDirective, + FingerprintPipe, + ], + providers: [ + CreditCardNumberPipe, + DatePipe, + I18nPipe, + SearchPipe, + UserNamePipe, + UserTypePipe, + FingerprintPipe, ], - providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe, UserTypePipe], }) export class JslibModule {} diff --git a/libs/angular/src/platform/pipes/fingerprint.pipe.ts b/libs/angular/src/platform/pipes/fingerprint.pipe.ts new file mode 100644 index 00000000000..198b3a57f7c --- /dev/null +++ b/libs/angular/src/platform/pipes/fingerprint.pipe.ts @@ -0,0 +1,32 @@ +import { Pipe } from "@angular/core"; + +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +@Pipe({ + name: "fingerprint", +}) +export class FingerprintPipe { + constructor(private cryptoService: CryptoService) {} + + async transform(publicKey: string | Uint8Array, fingerprintMaterial: string): Promise { + try { + if (typeof publicKey === "string") { + publicKey = Utils.fromB64ToArray(publicKey); + } + + const fingerprint = await this.cryptoService.getFingerprint( + fingerprintMaterial, + publicKey.buffer + ); + + if (fingerprint != null) { + return fingerprint.join("-"); + } + + return ""; + } catch { + return ""; + } + } +} diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index a172a2eeabd..d85c6a34571 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, Input } from "@angular/core"; import { Icons } from ".."; @@ -10,5 +10,5 @@ import { Icons } from ".."; templateUrl: "./no-items.component.html", }) export class NoItemsComponent { - protected icon = Icons.Search; + @Input() icon = Icons.Search; } diff --git a/libs/components/src/table/cell.directive.ts b/libs/components/src/table/cell.directive.ts index 058d90e5774..61c75571063 100644 --- a/libs/components/src/table/cell.directive.ts +++ b/libs/components/src/table/cell.directive.ts @@ -1,10 +1,10 @@ -import { HostBinding, Directive } from "@angular/core"; +import { Directive, HostBinding } from "@angular/core"; @Directive({ selector: "th[bitCell], td[bitCell]", }) export class CellDirective { @HostBinding("class") get classList() { - return ["tw-p-3", "tw-align-middle"]; + return ["tw-p-3"]; } } diff --git a/libs/components/src/table/row.directive.ts b/libs/components/src/table/row.directive.ts index 5d9eaca2613..19f3d3f775b 100644 --- a/libs/components/src/table/row.directive.ts +++ b/libs/components/src/table/row.directive.ts @@ -4,7 +4,7 @@ import { Directive, HostBinding, Input } from "@angular/core"; selector: "tr[bitRow]", }) export class RowDirective { - @Input() alignContent: "top" | "middle" | "bottom" | "baseline" = "baseline"; + @Input() alignContent: "top" | "middle" | "bottom" | "baseline" = "middle"; get alignmentClass(): string { switch (this.alignContent) { diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index 2d9830b7dad..9c1fac6956b 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,5 +1,5 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { countries } from "../form/countries"; @@ -62,7 +62,7 @@ export const Default: Story = { `, }), args: { - alignRowContent: "baseline", + alignRowContent: "middle", }, };