From ed0490730076298590421473f95b5391ff51d011 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 14 Jun 2023 13:09:56 -0700 Subject: [PATCH 01/13] [PM-2049] Update entity events dialog (#5417) * [AC-1145] Update entity-events.component.ts to a CL dialog - Add EntityEventsDialogParams - Add static helper method to open the dialog with the dialog service - Update existing usages of the entity-events.component.ts * [AC-1145] Update entity-events.component.ts to use CL components and form actions - Use bit-table and TableDataSource - Update to reactive form for date filter - Make dialog component standalone - Use bitAction in-place of component promises - Remove redundant try/catch that is now handled by bitAction and bitSubmit - Add new try/catch on first load to catch any errors during initial dialog open * [PM-2049] Make dataSource and filterFormGroup protected * [PM-2049] Remove bit-form-field container Remove the bit-form-field tags that wrapped the date inputs to avoid additional styling that is not applicable to inline form elements. Add back the missing `-` that was removed by mistake. * [PM-2049] Remove entity events dialog component selector --- .../manage/entity-events.component.html | 179 +++++++--------- .../manage/entity-events.component.ts | 191 +++++++++++------- .../members/people.component.html | 1 - .../organizations/members/people.component.ts | 20 +- .../src/app/shared/loose-components.module.ts | 3 - .../app/vault/org-vault/vault.component.html | 1 - .../app/vault/org-vault/vault.component.ts | 18 +- .../providers/manage/people.component.html | 1 - .../providers/manage/people.component.ts | 18 +- 9 files changed, 217 insertions(+), 215 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.html b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.html index 85c9d47492..8d8cfad34e 100644 --- a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.html @@ -1,118 +1,89 @@ - diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 8048ade1c7..7b6b926e49 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -56,7 +56,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v import { Icons } from "@bitwarden/components"; import { GroupService, GroupView } from "../../admin-console/organizations/core"; -import { EntityEventsComponent } from "../../admin-console/organizations/manage/entity-events.component"; +import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component"; import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; import { @@ -109,8 +109,6 @@ export class VaultComponent implements OnInit, OnDestroy { cipherAddEditModalRef: ViewContainerRef; @ViewChild("collectionsModal", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; - @ViewChild("eventsTemplate", { read: ViewContainerRef, static: true }) - eventsModalRef: ViewContainerRef; trashCleanupWarning: string = null; activeFilter: VaultFilter = new VaultFilter(); @@ -885,12 +883,14 @@ export class VaultComponent implements OnInit, OnDestroy { } async viewEvents(cipher: CipherView) { - await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => { - comp.name = cipher.name; - comp.organizationId = this.organization.id; - comp.entityId = cipher.id; - comp.showUser = true; - comp.entity = "cipher"; + await openEntityEventsDialog(this.dialogService, { + data: { + name: cipher.name, + organizationId: this.organization.id, + entityId: cipher.id, + showUser: true, + entity: "cipher", + }, }); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html index 00bf5eda26..152253fe4d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html @@ -209,7 +209,6 @@ - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts index 7c787951d4..304ab346f3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts @@ -21,7 +21,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { EntityEventsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; +import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; import { BasePeopleComponent } from "@bitwarden/web-vault/app/common/base.people.component"; @@ -41,8 +41,6 @@ export class PeopleComponent @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; - @ViewChild("eventsTemplate", { read: ViewContainerRef, static: true }) - eventsModalRef: ViewContainerRef; @ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true }) bulkStatusModalRef: ViewContainerRef; @ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true }) @@ -167,12 +165,14 @@ export class PeopleComponent } async events(user: ProviderUserUserDetailsResponse) { - await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => { - comp.name = this.userNamePipe.transform(user); - comp.providerId = this.providerId; - comp.entityId = user.id; - comp.showUser = false; - comp.entity = "user"; + await openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + providerId: this.providerId, + entityId: user.id, + showUser: false, + entity: "user", + }, }); } From 9ed59c6fa929d71291bdd3d954e6b6f3d87080f7 Mon Sep 17 00:00:00 2001 From: Daniel Chateau Date: Wed, 14 Jun 2023 16:59:29 -0400 Subject: [PATCH 02/13] Update request headers sent to AnonAddy API. (#5565) --- .../generator/username/email-forwarders/anon-addy-forwarder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts index 6b4bc42314..b20f22ecf8 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts @@ -18,6 +18,7 @@ export class AnonAddyForwarder implements Forwarder { headers: new Headers({ Authorization: "Bearer " + options.apiKey, "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", }), }; const url = "https://app.anonaddy.com/api/v1/aliases"; From 44f74483d96bddd2e00e374beedea42a12dfc19a Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Thu, 15 Jun 2023 12:43:36 -0400 Subject: [PATCH 03/13] Change ownership of autofill from vault to client integrations (#5619) --- .github/CODEOWNERS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a954236cdf..d0526b1a79 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,7 +31,6 @@ libs/exporter @bitwarden/team-tools-dev libs/importer @bitwarden/team-tools-dev ## Vault team files ## -apps/browser/src/autofill @bitwarden/team-vault-dev apps/browser/src/vault @bitwarden/team-vault-dev apps/cli/src/vault @bitwarden/team-vault-dev apps/desktop/src/vault @bitwarden/team-vault-dev @@ -69,6 +68,9 @@ apps/web/src/app/core @bitwarden/team-platform-dev apps/web/src/app/shared @bitwarden/team-platform-dev apps/web/src/translation-constants.ts @bitwarden/team-platform-dev +## Client Integrations team files ## +apps/browser/src/autofill @bitwarden/team-client-integrations-dev + ## Component Library ## libs/components @bitwarden/team-platform-dev From bec51c95f9b1e7a189ab8ae7e4a8d752bd590ddc Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Thu, 15 Jun 2023 12:54:39 -0400 Subject: [PATCH 04/13] Add EU Prod environment to Web build (#5620) --- .github/workflows/build-web.yml | 6 ++---- .github/workflows/deploy-prod-web.yml | 13 +++++++++++++ apps/web/config/euprd.json | 11 +++++++++++ apps/web/config/poc.json | 11 ----------- apps/web/package.json | 2 +- 5 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/deploy-prod-web.yml create mode 100644 apps/web/config/euprd.json delete mode 100644 apps/web/config/poc.json diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 05c6b0f8b6..fd4a700131 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -80,12 +80,10 @@ jobs: npm_command: "dist:bit:selfhost" - name: "cloud-QA" npm_command: "build:bit:qa" - - name: "cloud-POC2" - npm_command: "build:bit:poc" - name: "ee" npm_command: "build:bit:ee" - - name: "cloud-eudevtest" - npm_command: "build:bit:eudevtest" + - name: "cloud-euprd" + npm_command: "build:bit:euprd" steps: - name: Checkout repo diff --git a/.github/workflows/deploy-prod-web.yml b/.github/workflows/deploy-prod-web.yml new file mode 100644 index 0000000000..144b23e390 --- /dev/null +++ b/.github/workflows/deploy-prod-web.yml @@ -0,0 +1,13 @@ +--- +name: Deploy Web - EU Prod - STUB + +on: + workflow_dispatch: + +jobs: + stub-job: + name: Stub Job + runs-on: ubuntu-22.04 + steps: + - name: Stub Step + run: exit 0 diff --git a/apps/web/config/euprd.json b/apps/web/config/euprd.json new file mode 100644 index 0000000000..3813074b7c --- /dev/null +++ b/apps/web/config/euprd.json @@ -0,0 +1,11 @@ +{ + "urls": { + "icons": "https://icons.bitwarden.net", + "notifications": "https://notifications.bitwarden.net", + "scim": "https://scim.bitwarden.net" + }, + "flags": { + "secretsManager": true, + "showPasswordless": true + } +} diff --git a/apps/web/config/poc.json b/apps/web/config/poc.json deleted file mode 100644 index 0d2f4d2ec4..0000000000 --- a/apps/web/config/poc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "urls": { - "icons": "https://icons.poc2.bitwarden.pw", - "notifications": "https://notifications.poc2.bitwarden.pw", - "scim": "https://scim.poc2.bitwarden.pw" - }, - "flags": { - "secretsManager": true, - "showPasswordless": true - } -} diff --git a/apps/web/package.json b/apps/web/package.json index c50b4df2a8..1aed639fdc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "build:bit:dev:analyze": "cross-env LOGGING=false webpack -c ../../bitwarden_license/bit-web/webpack.config.js --profile --json > stats.json && npx webpack-bundle-analyzer stats.json build/", "build:bit:dev:watch": "cross-env ENV=development npm run build:bit:watch", "build:bit:qa": "cross-env NODE_ENV=production ENV=qa npm run build:bit", - "build:bit:poc": "cross-env NODE_ENV=production ENV=poc npm run build:bit", + "build:bit:euprd": "cross-env NODE_ENV=production ENV=euprd npm run build:bit", "build:bit:eudevtest": "cross-env NODE_ENV=production ENV=eudevtest npm run build:bit", "build:bit:cloud": "cross-env NODE_ENV=production ENV=cloud npm run build:bit", "build:oss:selfhost:watch": "cross-env ENV=selfhosted npm run build:oss:watch", From 0afbd90a2d7018fe58799cf82965196fd8d20836 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 15 Jun 2023 14:53:21 -0700 Subject: [PATCH 05/13] [AC-1192] Create new device approvals component for TDE (#5548) * Add feature flag route guard and tests * Add additional test for not showing error toast * Strengthen error toast test with message check * Cleanup leaking test state in platformService mock * Negate if statement to reduce nesting * Update return type to CanActivateFn * Use null check instead of undefined * Introduce interface to support different feature flag types - Switch to observable pattern to access serverConfig$ subject - Add catchError handler to allow navigation in case of unexpected exception - Add additional tests * Add additional test for missing feature flag * Remove subscription to the serverConfig observable Introduce type checking logic to determine the appropriately typed flag getter to use in configService * [AC-1192] Create initial device approvals component and route * [AC-1192] Introduce appIfFeature directive for conditionally rendering content based on feature flags * [AC-1192] Add DeviceApprovals link in Settings navigation * Remove align middle from bitCell directive The bitRow directive supports alignment for the entire row and should be used instead * [AC-1192] Add initial device approvals page template * [AC-1192] Introduce fingerprint pipe * [AC-1192] Create core organization module in bitwarden_license directory * [AC-1192] Add support for new Devices icon to no items component - Add new Devices svg - Make icon property of bit-no-items an Input property * [AC-1192] Introduce organization-auth-request.service.ts with related views/responses * [AC-1192] Display pending requests on device approvals page - Add support for loading spinner and no items component * [AC-1192] Add method to bulk deny auth requests * [AC-1192] Add functionality to deny requests from device approvals page * [AC-1192] Add organizationUserId to pending-auth-request.view.ts * [AC-1192] Add approvePendingRequest method to organization-auth-request.service.ts * [AC-1192] Add logic to approve a device approval request * [AC-1192] Change bitMenuItem directive into a component and implement ButtonLikeAbstraction Update the bitMenuItem to be a component and implement the ButtonLikeAbstraction to support the bitAction directive. * [AC-1192] Update menu items to use bitActions * [AC-1192] Update device approvals description copy * [AC-1192] Revert changes to bitMenuItem directive * [AC-1192] Rework menus to use click handlers - Wrap async actions to catch/log any exceptions, set an in-progress state, and refresh after completion - Show a loading spinner in the header when an action is in progress - Disable all menu items when an action is in progress * [AC-1192] Move Devices icon into admin-console web directory * [AC-1192] bit-no-items formatting * [AC-1192] Update appIfFeature directive to hide content on error * [AC-1192] Remove deprecated providedIn for OrganizationAuthRequestService * [AC-1192] Rename key to encryptedUserKey to be more descriptive * [AC-1192] Cleanup loading/spinner logic on data refresh * [AC-1192] Set middle as the default bitRow.alignContent * [AC-1192] Change default alignRowContent for table story * [AC-1192] Rename userId to fingerprintMaterial to be more general The fingerprint material is not always the userId so this name is more general * [AC-1192] Remove redundant alignContent attribute * [AC-1192] Move fingerprint pipe to platform --- .../src/app/admin-console/icons/devices.ts | 17 ++ apps/web/src/app/admin-console/icons/index.ts | 1 + .../settings/settings.component.html | 10 + .../settings/settings.component.ts | 2 + apps/web/src/locales/en/messages.json | 36 ++++ .../core/core-organization.module.ts | 8 + .../admin-console/organizations/core/index.ts | 1 + .../admin-auth-request-update.request.ts | 8 + .../bulk-deny-auth-requests.request.ts | 6 + .../core/services/auth-requests/index.ts | 2 + .../organization-auth-request.service.ts | 54 ++++++ ...ding-organization-auth-request.response.ts | 26 +++ .../core/views/pending-auth-request.view.ts | 23 +++ .../device-approvals.component.html | 100 ++++++++++ .../device-approvals.component.ts | 174 ++++++++++++++++++ .../organizations-routing.module.ts | 15 ++ .../organizations/organizations.module.ts | 6 +- .../directives/if-feature.directive.spec.ts | 137 ++++++++++++++ .../src/directives/if-feature.directive.ts | 67 +++++++ libs/angular/src/jslib.module.ts | 16 +- .../src/platform/pipes/fingerprint.pipe.ts | 32 ++++ .../src/no-items/no-items.component.ts | 4 +- libs/components/src/table/cell.directive.ts | 4 +- libs/components/src/table/row.directive.ts | 2 +- libs/components/src/table/table.stories.ts | 4 +- 25 files changed, 746 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/admin-console/icons/devices.ts create mode 100644 apps/web/src/app/admin-console/icons/index.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/core-organization.module.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/index.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/admin-auth-request-update.request.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/bulk-deny-auth-requests.request.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/index.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/organization-auth-request.service.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/services/auth-requests/pending-organization-auth-request.response.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/core/views/pending-auth-request.view.ts create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html create mode 100644 bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.ts create mode 100644 libs/angular/src/directives/if-feature.directive.spec.ts create mode 100644 libs/angular/src/directives/if-feature.directive.ts create mode 100644 libs/angular/src/platform/pipes/fingerprint.pipe.ts 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 0000000000..348c836c4b --- /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 0000000000..e0c2c124af --- /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 146a720343..bc2b2e54a0 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 9224bfe8d1..b9124dca16 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 0000000000..bba3bfce93 --- /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 0000000000..4d758be8c6 --- /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 0000000000..a190d5809c --- /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 0000000000..09054c4b58 --- /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 0000000000..d8c4bacd69 --- /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 0000000000..77e4cba033 --- /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 0000000000..b4854eea4a --- /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 0000000000..8f3415a236 --- /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 0000000000..4758cc47ce --- /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 0000000000..6325a72f80 --- /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 d1f71325ce..22cd2571ca 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 08f7dea640..3e939fa74f 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 0000000000..bf73a172a5 --- /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 0000000000..1a0ee35dc6 --- /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 bfe8f758f9..929875bbb2 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 0000000000..198b3a57f7 --- /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 a172a2eeab..d85c6a3457 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 058d90e577..61c7557106 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 5d9eaca261..19f3d3f775 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 2d9830b7da..9c1fac6956 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", }, }; From 5cd51374d7e746c36da77314d7d84f8361de36a1 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 15 Jun 2023 21:03:48 -0500 Subject: [PATCH 06/13] [AC-1416] Expose Organization Fingerprint (#5557) * refactor: change getFingerprint param to fingerprintMaterial, refs PM-1522 * feat: generate and show fingerprint for organization (WIP), refs AC-1416 * feat: update legacy params subscription to best practice (WIP), refs AC-1461 * refactor: update to use reactive forms, refs AC-1416 * refactor: remove boostrap specific classes and update to component library paradigms, refs AC-1416 * refactor: remove boostrap specific classes and update to component library paradigms, refs AC-1416 * refactor: create shared fingerprint component to redude boilerplate for settings fingerprint views, refs AC-1416 * refactor: use grid to emulate col-6 and remove unnecessary theme extensions, refs AC-1416 * refactor: remove negative margin and clean up extra divs, refs AC-1416 * [AC-1431] Add missing UserVerificationModule import (#5555) * [PM-2238] Add nord and solarize themes (#5491) * Fix simple configurable dialog stories (#5560) * chore(deps): update bitwarden/gh-actions digest to 72594be (#5523) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * refactor: remove extra div leftover from card-body class, refs AC-1416 * refactor: use bitTypography for headers, refs AC-1416 * fix: update crypto service abstraction path, refs AC-1416 * refactor: remove try/catch on handler, remove bootstrap class, update api chaining in observable, refs AC-1416 * fix: replace faulty combineLatest logic, refs AC-1416 * refactor: simplify observable logic again, refs AC-1416 --------- Co-authored-by: Shane Melton Co-authored-by: Oscar Hinton Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../settings/account.component.html | 103 +++++-------- .../settings/account.component.ts | 140 +++++++++++++----- .../settings/organization-settings.module.ts | 9 +- .../src/app/settings/profile.component.html | 18 +-- .../web/src/app/settings/profile.component.ts | 13 +- .../account-fingerprint.component.html | 16 ++ .../account-fingerprint.component.ts | 30 ++++ .../src/app/shared/loose-components.module.ts | 2 + apps/web/src/locales/en/messages.json | 4 + .../platform/abstractions/crypto.service.ts | 2 +- .../src/platform/services/crypto.service.ts | 4 +- 11 files changed, 206 insertions(+), 135 deletions(-) create mode 100644 apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.html create mode 100644 apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index 08059ed99f..c7ac9910ac 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -1,95 +1,66 @@ - +

{{ "organizationInfo" | i18n }}

- {{ "loading" | i18n }} + {{ "loading" | i18n }}
-
-
-
-
- - -
-
- - -
-
- - -
+ +
+
+ + {{ "organizationName" | i18n }} + + + + {{ "billingEmail" | i18n }} + + + + {{ "businessName" | i18n }} + +
-
+
+ +
- -
-

{{ "apiKey" | i18n }}

-
+

{{ "apiKey" | i18n }}

{{ "apiKeyDesc" | i18n }} {{ "learnMore" | i18n }}

- - -
-

{{ "dangerZone" | i18n }}

-
-
-
-

{{ "dangerZoneDesc" | i18n }}

- - -
+

{{ "dangerZone" | i18n }}

+
+

{{ "dangerZoneDesc" | i18n }}

+ +
diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index 2d0c1b17be..48dcb2c88c 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -1,6 +1,7 @@ import { Component, ViewChild, ViewContainerRef } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { lastValueFrom } from "rxjs"; +import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from } from "rxjs"; import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -13,6 +14,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se 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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { ApiKeyComponent } from "../../../settings/api-key.component"; import { PurgeVaultComponent } from "../../../settings/purge-vault.component"; @@ -23,7 +25,6 @@ import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./ selector: "app-org-account", templateUrl: "account.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil export class AccountComponent { @ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true }) purgeModalRef: ViewContainerRef; @@ -40,7 +41,29 @@ export class AccountComponent { formPromise: Promise; taxFormPromise: Promise; - private organizationId: string; + // FormGroup validators taken from server Organization domain object + protected formGroup = this.formBuilder.group({ + orgName: this.formBuilder.control( + { value: "", disabled: true }, + { + validators: [Validators.required, Validators.maxLength(50)], + updateOn: "change", + } + ), + billingEmail: this.formBuilder.control( + { value: "", disabled: true }, + { validators: [Validators.required, Validators.email, Validators.maxLength(256)] } + ), + businessName: this.formBuilder.control( + { value: "", disabled: true }, + { validators: [Validators.maxLength(50)] } + ), + }); + + protected organizationId: string; + protected publicKeyBuffer: ArrayBuffer; + + private destroy$ = new Subject(); constructor( private modalService: ModalService, @@ -52,53 +75,88 @@ export class AccountComponent { private router: Router, private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, - private dialogService: DialogServiceAbstraction + private dialogService: DialogServiceAbstraction, + private formBuilder: FormBuilder ) {} async ngOnInit() { this.selfHosted = this.platformUtilsService.isSelfHost(); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.parent.params.subscribe(async (params) => { - this.organizationId = params.organizationId; - this.canEditSubscription = this.organizationService.get( - this.organizationId - ).canEditSubscription; - try { - this.org = await this.organizationApiService.get(this.organizationId); - this.canUseApi = this.org.useApi; - } catch (e) { - this.logService.error(e); - } - }); - this.loading = false; + this.route.parent.parent.params + .pipe( + switchMap((params) => { + return combineLatest([ + // Organization domain + this.organizationService.get$(params.organizationId), + // OrganizationResponse for form population + from(this.organizationApiService.get(params.organizationId)), + // Organization Public Key + from(this.organizationApiService.getKeys(params.organizationId)), + ]); + }), + takeUntil(this.destroy$) + ) + .subscribe(([organization, orgResponse, orgKeys]) => { + // Set domain level organization variables + this.organizationId = organization.id; + this.canEditSubscription = organization.canEditSubscription; + this.canUseApi = organization.useApi; + + // Org Response + this.org = orgResponse; + + // Public Key Buffer for Org Fingerprint Generation + this.publicKeyBuffer = Utils.fromB64ToArray(orgKeys?.publicKey)?.buffer; + + // Patch existing values + this.formGroup.patchValue({ + orgName: this.org.name, + billingEmail: this.org.billingEmail, + businessName: this.org.businessName, + }); + + // Update disabled states - reactive forms prefers not using disabled attribute + if (!this.selfHosted) { + this.formGroup.get("orgName").enable(); + } + + if (!this.selfHosted || this.canEditSubscription) { + this.formGroup.get("billingEmail").enable(); + this.formGroup.get("businessName").enable(); + } + + this.loading = false; + }); } - async submit() { - try { - const request = new OrganizationUpdateRequest(); - request.name = this.org.name; - request.businessName = this.org.businessName; - request.billingEmail = this.org.billingEmail; + ngOnDestroy(): void { + // You must first call .next() in order for the notifier to properly close subscriptions using takeUntil + this.destroy$.next(); + this.destroy$.complete(); + } - // Backfill pub/priv key if necessary - if (!this.org.hasPublicAndPrivateKeys) { - const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); - const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); - request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); - } - - this.formPromise = this.organizationApiService.save(this.organizationId, request); - await this.formPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("organizationUpdated") - ); - } catch (e) { - this.logService.error(e); + submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; } - } + + const request = new OrganizationUpdateRequest(); + request.name = this.formGroup.value.orgName; + request.businessName = this.formGroup.value.businessName; + request.billingEmail = this.formGroup.value.billingEmail; + + // Backfill pub/priv key if necessary + if (!this.org.hasPublicAndPrivateKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + } + + this.formPromise = this.organizationApiService.save(this.organizationId, request); + await this.formPromise; + this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated")); + }; async deleteOrganization() { const dialog = openDeleteOrganizationDialog(this.dialogService, { diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts index c5500c6a94..9eaec0a068 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { LooseComponentsModule, SharedModule } from "../../../shared"; +import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component"; import { PoliciesModule } from "../../organizations/policies"; import { AccountComponent } from "./account.component"; @@ -9,7 +10,13 @@ import { SettingsComponent } from "./settings.component"; import { TwoFactorSetupComponent } from "./two-factor-setup.component"; @NgModule({ - imports: [SharedModule, LooseComponentsModule, PoliciesModule, OrganizationSettingsRoutingModule], + imports: [ + SharedModule, + LooseComponentsModule, + PoliciesModule, + OrganizationSettingsRoutingModule, + AccountFingerprintComponent, + ], declarations: [SettingsComponent, AccountComponent, TwoFactorSetupComponent], }) export class OrganizationSettingsModule {} diff --git a/apps/web/src/app/settings/profile.component.html b/apps/web/src/app/settings/profile.component.html index be0e58eaa1..91e0d4b985 100644 --- a/apps/web/src/app/settings/profile.component.html +++ b/apps/web/src/app/settings/profile.component.html @@ -46,19 +46,11 @@ Customize
-
-

- {{ "yourAccountsFingerprint" | i18n }}: - -
- {{ fingerprint }} -

+ +