From b7463d551c8ca8d1bd3f793c2e0ea47f8b74a8af Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 21 May 2024 12:32:02 -0400 Subject: [PATCH 01/13] [AC- 2493] Restore and Delete Unassigned Items (#8983) * updates added for single and bulk delete and restore items including unassigned and permissions for owners and custom users --- .../bulk-delete-dialog.component.html | 6 +- .../bulk-delete-dialog.component.ts | 20 +++- .../app/vault/org-vault/vault.component.ts | 92 +++++++++++++++---- .../src/vault/abstractions/cipher.service.ts | 6 +- .../src/vault/models/view/cipher.view.ts | 6 ++ .../src/vault/services/cipher.service.ts | 15 +-- 6 files changed, 109 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html index f4248331ffc..05a089c5d39 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html @@ -4,8 +4,8 @@ - - {{ "deleteSelectedItemsDesc" | i18n: cipherIds.length }} + + {{ "deleteSelectedItemsDesc" | i18n: cipherIds.length + unassignedCiphers.length }} {{ "deleteSelectedCollectionsDesc" | i18n: collections.length }} @@ -13,7 +13,7 @@ {{ "deleteSelectedConfirmation" | i18n }} - {{ "permanentlyDeleteSelectedItemsDesc" | i18n: cipherIds.length }} + {{ "permanentlyDeleteSelectedItemsDesc" | i18n: cipherIds.length + unassignedCiphers.length }} diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index ee036f5e3b3..c0de8c6bd22 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -20,6 +20,7 @@ export interface BulkDeleteDialogParams { organization?: Organization; organizations?: Organization[]; collections?: CollectionView[]; + unassignedCiphers?: string[]; } export enum BulkDeleteDialogResult { @@ -51,6 +52,7 @@ export class BulkDeleteDialogComponent { organization: Organization; organizations: Organization[]; collections: CollectionView[]; + unassignedCiphers: string[]; private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$( FeatureFlag.FlexibleCollectionsV1, @@ -75,6 +77,7 @@ export class BulkDeleteDialogComponent { this.organization = params.organization; this.organizations = params.organizations; this.collections = params.collections; + this.unassignedCiphers = params.unassignedCiphers || []; } protected async cancel() { @@ -83,6 +86,15 @@ export class BulkDeleteDialogComponent { protected submit = async () => { const deletePromises: Promise[] = []; + const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$); + + // Unassigned ciphers under an Owner/Admin OR Custom Users With Edit will call the deleteCiphersAdmin method + if ( + this.unassignedCiphers.length && + this.organization.canEditUnassignedCiphers(restrictProviderAccess) + ) { + deletePromises.push(this.deleteCiphersAdmin(this.unassignedCiphers)); + } if (this.cipherIds.length) { const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$); const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$); @@ -93,7 +105,7 @@ export class BulkDeleteDialogComponent { ) { deletePromises.push(this.deleteCiphers()); } else { - deletePromises.push(this.deleteCiphersAdmin()); + deletePromises.push(this.deleteCiphersAdmin(this.cipherIds)); } } @@ -103,7 +115,7 @@ export class BulkDeleteDialogComponent { await Promise.all(deletePromises); - if (this.cipherIds.length) { + if (this.cipherIds.length || this.unassignedCiphers.length) { this.platformUtilsService.showToast( "success", null, @@ -135,8 +147,8 @@ export class BulkDeleteDialogComponent { } } - private async deleteCiphersAdmin(): Promise { - const deleteRequest = new CipherBulkDeleteRequest(this.cipherIds, this.organization.id); + private async deleteCiphersAdmin(ciphers: string[]): Promise { + const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id); if (this.permanent) { return await this.apiService.deleteManyCiphersAdmin(deleteRequest); } else { 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 2ddc0c116d3..0247b89bfd0 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -751,7 +751,7 @@ export class VaultComponent implements OnInit, OnDestroy { if (ciphers.length === 1 && collections.length === 0) { await this.deleteCipher(ciphers[0]); } else if (ciphers.length === 0 && collections.length === 1) { - await this.deleteCollection(collections[0]); + await this.deleteCollection(collections[0] as CollectionAdminView); } else { await this.bulkDelete(ciphers, collections, this.organization); } @@ -980,6 +980,7 @@ export class VaultComponent implements OnInit, OnDestroy { } if ( + !this.organization.permissions.editAnyCollection && this.flexibleCollectionsV1Enabled && !c.edit && !this.organization.allowAdminAccessToAllCollectionItems @@ -992,8 +993,11 @@ export class VaultComponent implements OnInit, OnDestroy { return; } + // Allow restore of an Unassigned Item try { - const asAdmin = this.organization?.canEditAnyCollection(this.flexibleCollectionsV1Enabled); + const asAdmin = + this.organization?.canEditAnyCollection(this.flexibleCollectionsV1Enabled) || + c.isUnassigned; await this.cipherService.restoreWithServer(c.id, asAdmin); this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem")); this.refresh(); @@ -1004,6 +1008,7 @@ export class VaultComponent implements OnInit, OnDestroy { async bulkRestore(ciphers: CipherView[]) { if ( + !this.organization.permissions.editAnyCollection && this.flexibleCollectionsV1Enabled && ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems) ) { @@ -1015,13 +1020,46 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const selectedCipherIds = ciphers.map((cipher) => cipher.id); - if (selectedCipherIds.length === 0) { - this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected")); + // assess if there are unassigned ciphers and/or editable ciphers selected in bulk for restore + const editAccessCiphers: string[] = []; + const unassignedCiphers: string[] = []; + + // If user has edit all Access no need to check for unassigned ciphers + const canEditAll = this.organization.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccessEnabled, + ); + + if (canEditAll) { + ciphers.map((cipher) => { + editAccessCiphers.push(cipher.id); + }); + } else { + ciphers.map((cipher) => { + if (cipher.collectionIds.length === 0) { + unassignedCiphers.push(cipher.id); + } else if (cipher.edit) { + editAccessCiphers.push(cipher.id); + } + }); + } + + if (unassignedCiphers.length === 0 && editAccessCiphers.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected"), + ); return; } - await this.cipherService.restoreManyWithServer(selectedCipherIds); + if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) { + await this.cipherService.restoreManyWithServer( + [...unassignedCiphers, ...editAccessCiphers], + this.organization.id, + ); + } + this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItems")); this.refresh(); } @@ -1030,7 +1068,10 @@ export class VaultComponent implements OnInit, OnDestroy { if ( this.flexibleCollectionsV1Enabled && !c.edit && - !this.organization.allowAdminAccessToAllCollectionItems + !this.organization.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccessEnabled, + ) ) { this.showMissingPermissionsError(); return; @@ -1053,7 +1094,7 @@ export class VaultComponent implements OnInit, OnDestroy { } try { - await this.deleteCipherWithServer(c.id, permanent); + await this.deleteCipherWithServer(c.id, permanent, c.isUnassigned); this.platformUtilsService.showToast( "success", null, @@ -1065,7 +1106,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async deleteCollection(collection: CollectionView): Promise { + async deleteCollection(collection: CollectionAdminView): Promise { if (!collection.canDelete(this.organization, this.flexibleCollectionsV1Enabled)) { this.showMissingPermissionsError(); return; @@ -1111,6 +1152,18 @@ export class VaultComponent implements OnInit, OnDestroy { return; } + // Allow bulk deleting of Unassigned Items + const unassignedCiphers: string[] = []; + const assignedCiphers: string[] = []; + + ciphers.map((c) => { + if (c.isUnassigned) { + unassignedCiphers.push(c.id); + } else { + assignedCiphers.push(c.id); + } + }); + if (ciphers.length === 0 && collections.length === 0) { this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected")); return; @@ -1121,8 +1174,11 @@ export class VaultComponent implements OnInit, OnDestroy { collections.every((c) => c.canDelete(organization, this.flexibleCollectionsV1Enabled)); const canDeleteCiphers = ciphers == null || - this.organization.allowAdminAccessToAllCollectionItems || - ciphers.every((c) => c.edit); + ciphers.every((c) => c.edit) || + this.organization.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccessEnabled, + ); if (this.flexibleCollectionsV1Enabled && (!canDeleteCiphers || !canDeleteCollections)) { this.showMissingPermissionsError(); @@ -1132,9 +1188,10 @@ export class VaultComponent implements OnInit, OnDestroy { const dialog = openBulkDeleteDialog(this.dialogService, { data: { permanent: this.filter.type === "trash", - cipherIds: ciphers.map((c) => c.id), + cipherIds: assignedCiphers, collections: collections, organization, + unassignedCiphers, }, }); @@ -1331,11 +1388,12 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - protected deleteCipherWithServer(id: string, permanent: boolean) { - const asAdmin = this.organization?.canEditAllCiphers( - this.flexibleCollectionsV1Enabled, - this.restrictProviderAccessEnabled, - ); + protected deleteCipherWithServer(id: string, permanent: boolean, isUnassigned: boolean) { + const asAdmin = + this.organization?.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccessEnabled, + ) || isUnassigned; return permanent ? this.cipherService.deleteWithServer(id, asAdmin) : this.cipherService.softDeleteWithServer(id, asAdmin); diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 22e2c54a59a..d559b18f06a 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -132,11 +132,7 @@ export abstract class CipherService { cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], ) => Promise; restoreWithServer: (id: string, asAdmin?: boolean) => Promise; - restoreManyWithServer: ( - ids: string[], - organizationId?: string, - asAdmin?: boolean, - ) => Promise; + restoreManyWithServer: (ids: string[], orgId?: string) => Promise; getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise; } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 911a78f565b..028b582db26 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -126,6 +126,12 @@ export class CipherView implements View, InitializerMetadata { return this.item?.linkedFieldOptions; } + get isUnassigned(): boolean { + return ( + this.organizationId != null && (this.collectionIds == null || this.collectionIds.length === 0) + ); + } + linkedFieldValue(id: LinkedIdType) { const linkedFieldOption = this.linkedFieldOptions?.get(id); if (linkedFieldOption == null) { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 537245459eb..0e6ddf40caa 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1117,14 +1117,15 @@ export class CipherService implements CipherServiceAbstraction { await this.restore({ id: id, revisionDate: response.revisionDate }); } - async restoreManyWithServer( - ids: string[], - organizationId: string = null, - asAdmin = false, - ): Promise { + /** + * No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable + * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore + */ + async restoreManyWithServer(ids: string[], orgId: string = null): Promise { let response; - if (asAdmin) { - const request = new CipherBulkRestoreRequest(ids, organizationId); + + if (orgId) { + const request = new CipherBulkRestoreRequest(ids, orgId); response = await this.apiService.putRestoreManyCiphersAdmin(request); } else { const request = new CipherBulkRestoreRequest(ids); From 5075d0865e79161577cd4a26a2b065cdb0aaa2f5 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 21 May 2024 12:32:27 -0400 Subject: [PATCH 02/13] [AC-2447] Allow the UI to save and close dialog when user removes final Can Manage Collection of an item (#9136) * update saveCollectionsWithServer to accept a new value if user can no longer manage cipher after requested update --- libs/common/src/abstractions/api.service.ts | 6 +++++- libs/common/src/services/api.service.ts | 13 ++++++++++--- .../models/response/optional-cipher.response.ts | 14 ++++++++++++++ libs/common/src/vault/services/cipher.service.ts | 9 +++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 libs/common/src/vault/models/response/optional-cipher.response.ts diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index c1a0e1f9cd9..73e4f74e63f 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -123,6 +123,7 @@ import { CollectionDetailsResponse, CollectionResponse, } from "../vault/models/response/collection.response"; +import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; import { SyncResponse } from "../vault/models/response/sync.response"; /** @@ -218,7 +219,10 @@ export abstract class ApiService { putMoveCiphers: (request: CipherBulkMoveRequest) => Promise; putShareCipher: (id: string, request: CipherShareRequest) => Promise; putShareCiphers: (request: CipherBulkShareRequest) => Promise; - putCipherCollections: (id: string, request: CipherCollectionsRequest) => Promise; + putCipherCollections: ( + id: string, + request: CipherCollectionsRequest, + ) => Promise; putCipherCollectionsAdmin: (id: string, request: CipherCollectionsRequest) => Promise; postPurgeCiphers: (request: SecretVerificationRequest, organizationId?: string) => Promise; putDeleteCipher: (id: string) => Promise; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 4620a2ccdee..8d7a53ec0e4 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -138,6 +138,7 @@ import { CollectionDetailsResponse, CollectionResponse, } from "../vault/models/response/collection.response"; +import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; import { SyncResponse } from "../vault/models/response/sync.response"; /** @@ -566,9 +567,15 @@ export class ApiService implements ApiServiceAbstraction { async putCipherCollections( id: string, request: CipherCollectionsRequest, - ): Promise { - const response = await this.send("PUT", "/ciphers/" + id + "/collections", request, true, true); - return new CipherResponse(response); + ): Promise { + const response = await this.send( + "PUT", + "/ciphers/" + id + "/collections_v2", + request, + true, + true, + ); + return new OptionalCipherResponse(response); } putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise { diff --git a/libs/common/src/vault/models/response/optional-cipher.response.ts b/libs/common/src/vault/models/response/optional-cipher.response.ts new file mode 100644 index 00000000000..08181407b24 --- /dev/null +++ b/libs/common/src/vault/models/response/optional-cipher.response.ts @@ -0,0 +1,14 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +import { CipherResponse } from "./cipher.response"; + +export class OptionalCipherResponse extends BaseResponse { + unavailable: boolean; + cipher?: CipherResponse; + + constructor(response: any) { + super(response); + this.unavailable = this.getResponseProperty("Unavailable"); + this.cipher = new CipherResponse(this.getResponseProperty("Cipher")); + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 0e6ddf40caa..c03b440ff58 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -776,9 +776,14 @@ export class CipherService implements CipherServiceAbstraction { async saveCollectionsWithServer(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); const response = await this.apiService.putCipherCollections(cipher.id, request); - const data = new CipherData(response); + // The response will now check for an unavailable value. This value determines whether + // the user still has Can Manage access to the item after updating. + if (response.unavailable) { + await this.delete(cipher.id); + return; + } + const data = new CipherData(response.cipher); const updated = await this.upsert(data); - // Collection updates don't change local data return new Cipher(updated[cipher.id as CipherId], cipher.localData); } From 644fe9a5b0784112e2b347b2637f8c8dd14769fb Mon Sep 17 00:00:00 2001 From: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Date: Tue, 21 May 2024 23:27:29 +0530 Subject: [PATCH 03/13] [AC 2413] migrate policies component (#8692) * migrate policies component * migrate policies component --- .../policies/policies.component.html | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index d4533b59b16..8f1e925034f 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -3,24 +3,24 @@ - {{ "loading" | i18n }} + {{ "loading" | i18n }} - - - - + - -
- {{ p.name | i18n }} + + +
+ {{ "on" | i18n }} - {{ p.description | i18n }} + {{ p.description | i18n }}
+ +
From f0a3d942c7ee8dc84d5682fc393cddb9dce9c776 Mon Sep 17 00:00:00 2001 From: Alex Urbina <42731074+urbinaalex17@users.noreply.github.com> Date: Tue, 21 May 2024 12:07:04 -0600 Subject: [PATCH 04/13] BRE-40 ADD: step to report upcoming release version to Slack (#9201) --- .github/workflows/version-bump.yml | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 246ca9a533d..0341f2a57af 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -452,6 +452,38 @@ jobs: PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} run: gh pr merge $PR_NUMBER --squash --auto --delete-branch + - name: Report upcoming browser release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} && ${{ steps.set-final-version-output.outputs.version_browser != '' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version_browser }} + project: browser + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Report upcoming cli release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} && ${{ steps.set-final-version-output.outputs.version_cli != '' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version_cli }} + project: cli + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Report upcoming desktop release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} && ${{ steps.set-final-version-output.outputs.version_desktop != '' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version_desktop }} + project: desktop + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Report upcoming web release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} && ${{ steps.set-final-version-output.outputs.version_web != '' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version_web }} + project: web + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + cut_rc: name: Cut RC branch if: ${{ inputs.cut_rc_branch == true }} From acb153520e73069679b43edb38eb398643f01863 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Tue, 21 May 2024 19:22:15 +0100 Subject: [PATCH 05/13] [BRE-50] - Update Slack Notif Channel Ref (#9267) * delete slack notif channel ref * update slack channel reference --- .github/workflows/deploy-web.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index b034136f585..1ff67671419 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -224,7 +224,7 @@ jobs: project: Clients environment: ${{ needs.setup.outputs.environment-name }} tag: ${{ inputs.branch-or-tag }} - slack-channel: team-eng-qa-devops + slack-channel: alerts-deploy-qa event: 'start' commit-sha: ${{ needs.artifact-check.outputs.artifact-build-commit }} url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }} From cdf93df8980da8c64fb9442270e119381c2ff88a Mon Sep 17 00:00:00 2001 From: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Date: Wed, 22 May 2024 00:02:09 +0530 Subject: [PATCH 06/13] migrate update license component (#8652) --- .../shared/update-license.component.html | 41 +++++++--- .../shared/update-license.component.ts | 76 ++++++++++--------- 2 files changed, 71 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/billing/shared/update-license.component.html b/apps/web/src/app/billing/shared/update-license.component.html index 56058b158e8..37eaa30c64c 100644 --- a/apps/web/src/app/billing/shared/update-license.component.html +++ b/apps/web/src/app/billing/shared/update-license.component.html @@ -1,20 +1,39 @@ -
-
- - - {{ + + + {{ "licenseFile" | i18n }} +
+ + {{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }} +
+ + {{ "licenseFileDesc" | i18n : (!organizationId ? "bitwarden_premium_license.json" : "bitwarden_organization_license.json") - }}
-
- -
diff --git a/apps/web/src/app/billing/shared/update-license.component.ts b/apps/web/src/app/billing/shared/update-license.component.ts index 2c24565d710..30b5983090b 100644 --- a/apps/web/src/app/billing/shared/update-license.component.ts +++ b/apps/web/src/app/billing/shared/update-license.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; 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"; @Component({ @@ -17,19 +17,30 @@ export class UpdateLicenseComponent { @Output() onCanceled = new EventEmitter(); formPromise: Promise; - + title: string = this.i18nService.t("updateLicense"); + updateLicenseForm = this.formBuilder.group({ + file: [null, Validators.required], + }); + licenseFile: File = null; constructor( private apiService: ApiService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private organizationApiService: OrganizationApiServiceAbstraction, + private formBuilder: FormBuilder, ) {} - - async submit() { - const fileEl = document.getElementById("file") as HTMLInputElement; - const files = fileEl.files; - if (files == null || files.length === 0) { + protected setSelectedFile(event: Event) { + const fileInputEl = event.target; + const file: File = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; + this.licenseFile = file; + } + submit = async () => { + this.updateLicenseForm.markAllAsTouched(); + if (this.updateLicenseForm.invalid) { + return; + } + const files = this.licenseFile; + if (files == null) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -37,35 +48,30 @@ export class UpdateLicenseComponent { ); return; } + const fd = new FormData(); + fd.append("license", files); - try { - const fd = new FormData(); - fd.append("license", files[0]); - - let updatePromise: Promise = null; - if (this.organizationId == null) { - updatePromise = this.apiService.postAccountLicense(fd); - } else { - updatePromise = this.organizationApiService.updateLicense(this.organizationId, fd); - } - - this.formPromise = updatePromise.then(() => { - return this.apiService.refreshIdentityToken(); - }); - - await this.formPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("licenseUploadSuccess"), - ); - this.onUpdated.emit(); - } catch (e) { - this.logService.error(e); + let updatePromise: Promise = null; + if (this.organizationId == null) { + updatePromise = this.apiService.postAccountLicense(fd); + } else { + updatePromise = this.organizationApiService.updateLicense(this.organizationId, fd); } - } - cancel() { + this.formPromise = updatePromise.then(() => { + return this.apiService.refreshIdentityToken(); + }); + + await this.formPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("licenseUploadSuccess"), + ); + this.onUpdated.emit(); + }; + + cancel = () => { this.onCanceled.emit(); - } + }; } From 4ac67f278782fcd651a5ad35f251e77434781c32 Mon Sep 17 00:00:00 2001 From: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Date: Wed, 22 May 2024 00:12:32 +0530 Subject: [PATCH 07/13] change security keys component migration (#8496) --- .../auth/settings/security/security-keys.component.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.html b/apps/web/src/app/auth/settings/security/security-keys.component.html index 1ffc1607e7d..acfe4319c95 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.html +++ b/apps/web/src/app/auth/settings/security/security-keys.component.html @@ -1,11 +1,11 @@
-

{{ "apiKey" | i18n }}

+

{{ "apiKey" | i18n }}

-

+

{{ "userApiKeyDesc" | i18n }}

+ + + + {{ "exportVault" | i18n }} + + + + + + + + diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts new file mode 100644 index 00000000000..c969f0436df --- /dev/null +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { Router, RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +@Component({ + templateUrl: "vault-settings-v2.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + RouterModule, + PopupPageComponent, + PopupFooterComponent, + PopupHeaderComponent, + PopOutComponent, + ItemModule, + ], +}) +export class VaultSettingsV2Component implements OnInit { + lastSync = "--"; + + constructor( + private router: Router, + private syncService: SyncService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + async ngOnInit() { + await this.setLastSync(); + } + + async import() { + await this.router.navigate(["/import"]); + if (await BrowserApi.isPopupOpen()) { + await BrowserPopupUtils.openCurrentPagePopout(window); + } + } + + async sync() { + let toastConfig: ToastOptions; + const success = await this.syncService.fullSync(true); + if (success) { + await this.setLastSync(); + toastConfig = { + variant: "success", + title: "", + message: this.i18nService.t("syncingComplete"), + }; + } else { + toastConfig = { variant: "error", title: "", message: this.i18nService.t("syncingFailed") }; + } + this.toastService.showToast(toastConfig); + } + + private async setLastSync() { + const last = await this.syncService.getLastSync(); + if (last != null) { + this.lastSync = last.toLocaleDateString() + " " + last.toLocaleTimeString(); + } else { + this.lastSync = this.i18nService.t("never"); + } + } +} From 3ba19d8c9d59d5559024cbc198b82121ac0e5c1a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 21 May 2024 22:58:37 +0200 Subject: [PATCH 10/13] Add missing RouterModule to the CurrentAccountComponent (#9295) Co-authored-by: Daniel James Smith --- .../auth/popup/account-switching/current-account.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index fcb772f0245..6c7c1e7d92f 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -1,6 +1,6 @@ import { CommonModule, Location } from "@angular/common"; import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { Observable, combineLatest, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -23,7 +23,7 @@ export type CurrentAccount = { selector: "app-current-account", templateUrl: "current-account.component.html", standalone: true, - imports: [CommonModule, JslibModule, AvatarModule], + imports: [CommonModule, JslibModule, AvatarModule, RouterModule], }) export class CurrentAccountComponent { currentAccount$: Observable; From 3d0e0d261e84843100a71ccb1461210e478f0c55 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 21 May 2024 14:05:02 -0700 Subject: [PATCH 11/13] [PM-6825] Browser Refresh - Initial List Items (#9199) * [PM-6825] Add temporary vault page header * [PM-6825] Expose cipherViews$ observable * [PM-6825] Refactor getAllDecryptedForUrl to expose filter functionality for reuse * [PM-6825] Introduce VaultPopupItemsService * [PM-6825] Introduce initial VaultListItem and VaultListItemsContainer components * [PM-6825] Add VaultListItems to VaultV2 component * [PM-6825] Introduce autofill-vault-list-items.component to encapsulate autofill logic * [PM-6825] Add temporary Vault icon * [PM-6825] Add empty and no results states to Vault tab * [PM-6825] Add unit tests for vault popup items service * [PM-6825] Negate noFilteredResults placeholder * [PM-6825] Cleanup new Vault components * [PM-6825] Move new components into its own module * [PM-6825] Fix missing button type * [PM-6825] Add booleanAttribute to showAutofill input * [PM-6825] Replace empty refresh BehaviorSubject with Subject * [PM-6825] Combine *ngIfs for vault list items container * [PM-6825] Use popup-section-header component * [PM-6825] Use small variant for icon buttons * [PM-6825] Use anchor tag for vault items * [PM-6825] Consolidate vault-list-items-container to include list item component functionality directly * [PM-6825] Add Tailwind classes to new Vault icon * [PM-6825] Remove temporary header comment * [PM-6825] Fix auto fill suggestion font size and padding * [PM-6825] Use tailwind for vault icon styling * [PM-6825] Add libs/angular to tailwind.config content * [PM-6825] Cleanup missing i18n * [PM-6825] Make VaultV2 standalone and cleanup Browser App module * [PM-6825] Use explicit type annotation * [PM-6825] Use property binding instead of interpolation --- apps/browser/src/_locales/en/messages.json | 35 +++ apps/browser/src/popup/app.module.ts | 2 - .../autofill-vault-list-items.component.html | 14 + .../autofill-vault-list-items.component.ts | 51 ++++ .../vault/popup/components/vault-v2/index.ts | 2 + .../vault-list-items-container.component.html | 35 +++ .../vault-list-items-container.component.ts | 44 ++++ .../components/vault/vault-v2.component.html | 36 +++ .../components/vault/vault-v2.component.ts | 44 +++- .../vault-popup-items.service.spec.ts | 248 ++++++++++++++++++ .../services/vault-popup-items.service.ts | 186 +++++++++++++ apps/browser/tailwind.config.js | 6 +- apps/desktop/src/scss/misc.scss | 11 + apps/desktop/tailwind.config.js | 6 +- apps/web/tailwind.config.js | 1 + .../src/vault/components/icon.component.html | 3 +- .../src/vault/abstractions/cipher.service.ts | 7 + .../src/vault/services/cipher.service.ts | 21 +- libs/components/src/icon/icons/index.ts | 1 + libs/components/src/icon/icons/vault.ts | 17 ++ libs/components/tailwind.config.base.js | 1 + 21 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/index.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts create mode 100644 apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts create mode 100644 apps/browser/src/vault/popup/services/vault-popup-items.service.ts create mode 100644 libs/components/src/icon/icons/vault.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 28ad7b2d42e..553055439cc 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3137,6 +3137,41 @@ "message": "to make them visible.", "description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible." }, + "autofillSuggestions": { + "message": "Auto-fill suggestions" + }, + "autofillSuggestionsTip": { + "message": "Save a login item for this site to auto-fill" + }, + "yourVaultIsEmpty": { + "message": "Your vault is empty" + }, + "noItemsMatchSearch": { + "message": "No items match your search" + }, + "clearFiltersOrTryAnother": { + "message": "Clear filters or try another search term" + }, + "copyInfo": { + "message": "Copy info, $ITEMNAME$", + "description": "Aria label for a button that opens a menu with options to copy information from an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "moreOptions": { + "message": "More options, $ITEMNAME$", + "description": "Aria label for a button that opens a menu with more options for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, "adminConsole": { "message": "Admin Console" }, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 2f69d8253f9..cbe7025e588 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -72,7 +72,6 @@ import { ShareComponent } from "../vault/popup/components/vault/share.component" import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component"; import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component"; import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component"; -import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component"; import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; @@ -190,7 +189,6 @@ import "../platform/popup/locales"; AutofillComponent, EnvironmentSelectorComponent, AccountSwitcherComponent, - VaultV2Component, ], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent], diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html new file mode 100644 index 00000000000..d1735a8efe3 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -0,0 +1,14 @@ + + + + + {{ + "autofillSuggestionsTip" | i18n + }} + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts new file mode 100644 index 00000000000..99662393bd6 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -0,0 +1,51 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { SectionComponent, TypographyModule } from "@bitwarden/components"; + +import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + SectionComponent, + TypographyModule, + VaultListItemsContainerComponent, + JslibModule, + PopupSectionHeaderComponent, + ], + selector: "app-autofill-vault-list-items", + templateUrl: "autofill-vault-list-items.component.html", +}) +export class AutofillVaultListItemsComponent { + /** + * The list of ciphers that can be used to autofill the current page. + * @protected + */ + protected autofillCiphers$: Observable = + this.vaultPopupItemsService.autoFillCiphers$; + + /** + * Observable that determines whether the empty autofill tip should be shown. + * The tip is shown when there are no ciphers to autofill, no filter is applied, and autofill is allowed in + * the current context (e.g. not in a popout). + * @protected + */ + protected showEmptyAutofillTip$: Observable = combineLatest([ + this.vaultPopupItemsService.hasFilterApplied$, + this.autofillCiphers$, + this.vaultPopupItemsService.autofillAllowed$, + ]).pipe( + map(([hasFilter, ciphers, canAutoFill]) => !hasFilter && canAutoFill && ciphers.length === 0), + ); + + constructor(private vaultPopupItemsService: VaultPopupItemsService) { + // TODO: Migrate logic to show Autofill policy toast PM-8144 + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/index.ts b/apps/browser/src/vault/popup/components/vault-v2/index.ts new file mode 100644 index 00000000000..13618d007d2 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/index.ts @@ -0,0 +1,2 @@ +export * from "./vault-list-items-container/vault-list-items-container.component"; +export * from "./autofill-vault-list-items/autofill-vault-list-items.component"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html new file mode 100644 index 00000000000..55463a85f84 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -0,0 +1,35 @@ + + + {{ ciphers.length }} + + + + + + {{ cipher.name }} + {{ cipher.subTitle }} + + + + + + + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts new file mode 100644 index 00000000000..27ee0a2cc0e --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { booleanAttribute, Component, Input } from "@angular/core"; +import { RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeModule, + ButtonModule, + IconButtonModule, + ItemModule, + SectionComponent, + TypographyModule, +} from "@bitwarden/components"; + +import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; + +@Component({ + imports: [ + CommonModule, + ItemModule, + ButtonModule, + BadgeModule, + IconButtonModule, + SectionComponent, + TypographyModule, + JslibModule, + PopupSectionHeaderComponent, + RouterLink, + ], + selector: "app-vault-list-items-container", + templateUrl: "vault-list-items-container.component.html", + standalone: true, +}) +export class VaultListItemsContainerComponent { + @Input() + ciphers: CipherView[]; + + @Input() + title: string; + + @Input({ transform: booleanAttribute }) + showAutoFill: boolean; +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index c36d2d2db99..a9814f892ec 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -10,4 +10,40 @@
+ +
+ + {{ "yourVaultIsEmpty" | i18n }} + {{ "autofillSuggestionsTip" | i18n }} + + +
+ + + + +
+ + {{ "noItemsMatchSearch" | i18n }} + {{ "clearFiltersOrTryAnother" | i18n }} + +
+ + + + + + +
diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts index 332e5d1a4e7..7e0be4607b4 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts @@ -1,13 +1,55 @@ +import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Router, RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; + +import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; +import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; +import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; +import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2"; @Component({ selector: "app-vault", templateUrl: "vault-v2.component.html", + standalone: true, + imports: [ + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + CurrentAccountComponent, + NoItemsModule, + JslibModule, + CommonModule, + AutofillVaultListItemsComponent, + VaultListItemsContainerComponent, + ButtonModule, + RouterLink, + ], }) export class VaultV2Component implements OnInit, OnDestroy { - constructor() {} + protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; + protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; + + protected showEmptyState$ = this.vaultPopupItemsService.emptyVault$; + protected showNoResultsState$ = this.vaultPopupItemsService.noFilteredResults$; + + protected vaultIcon = Icons.Vault; + + constructor( + private vaultPopupItemsService: VaultPopupItemsService, + private router: Router, + ) {} ngOnInit(): void {} ngOnDestroy(): void {} + + addCipher() { + // TODO: Add currently filtered organization to query params if available + void this.router.navigate(["/add-cipher"], {}); + } } diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts new file mode 100644 index 00000000000..1830d4be35e --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -0,0 +1,248 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +import { VaultPopupItemsService } from "./vault-popup-items.service"; + +describe("VaultPopupItemsService", () => { + let service: VaultPopupItemsService; + let allCiphers: Record; + let autoFillCiphers: CipherView[]; + + const cipherServiceMock = mock(); + const vaultSettingsServiceMock = mock(); + + beforeEach(() => { + allCiphers = cipherFactory(10); + const cipherList = Object.values(allCiphers); + // First 2 ciphers are autofill + autoFillCiphers = cipherList.slice(0, 2); + + // First autofill cipher is also favorite + autoFillCiphers[0].favorite = true; + + // 3rd and 4th ciphers are favorite + cipherList[2].favorite = true; + cipherList[3].favorite = true; + + cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); + cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest + .spyOn(BrowserApi, "getTabFromCurrentWindow") + .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + }); + + it("should be created", () => { + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + expect(service).toBeTruthy(); + }); + + describe("autoFillCiphers$", () => { + it("should return empty array if there is no current tab", (done) => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers).toEqual([]); + done(); + }); + }); + + it("should return empty array if in Popout window", (done) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers).toEqual([]); + done(); + }); + }); + + it("should filter ciphers for the current tab and types", (done) => { + const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; + + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); + + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); + expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( + expect.anything(), + currentTab.url, + [CipherType.Card, CipherType.Identity], + ); + done(); + }); + }); + + it("should return ciphers sorted by type, then by last used date, then by name", (done) => { + const expectedTypeOrder: Record = { + [CipherType.Login]: 1, + [CipherType.Card]: 2, + [CipherType.Identity]: 3, + [CipherType.SecureNote]: 4, + }; + + // Assume all ciphers are autofill ciphers to test sorting + cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => + Object.values(allCiphers), + ); + + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers.length).toBe(10); + + for (let i = 0; i < ciphers.length - 1; i++) { + const current = ciphers[i]; + const next = ciphers[i + 1]; + + expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]); + } + expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("favoriteCiphers$", () => { + it("should exclude autofill ciphers", (done) => { + service.favoriteCiphers$.subscribe((ciphers) => { + // 2 autofill ciphers, 3 favorite ciphers, 1 favorite cipher is also autofill = 2 favorite ciphers to show + expect(ciphers.length).toBe(2); + done(); + }); + }); + + it("should sort by last used then by name", (done) => { + service.favoriteCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("remainingCiphers$", () => { + it("should exclude autofill and favorite ciphers", (done) => { + service.remainingCiphers$.subscribe((ciphers) => { + // 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show + expect(ciphers.length).toBe(6); + done(); + }); + }); + + it("should sort by last used then by name", (done) => { + service.remainingCiphers$.subscribe((ciphers) => { + expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe("emptyVault$", () => { + it("should return true if there are no ciphers", (done) => { + cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); + service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock); + service.emptyVault$.subscribe((empty) => { + expect(empty).toBe(true); + done(); + }); + }); + + it("should return false if there are ciphers", (done) => { + service.emptyVault$.subscribe((empty) => { + expect(empty).toBe(false); + done(); + }); + }); + }); + + describe("autoFillAllowed$", () => { + it("should return true if there is a current tab", (done) => { + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(true); + done(); + }); + }); + + it("should return false if there is no current tab", (done) => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(false); + done(); + }); + }); + + it("should return false if in a Popout", (done) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + service.autofillAllowed$.subscribe((allowed) => { + expect(allowed).toBe(false); + done(); + }); + }); + }); +}); + +// A function to generate a list of ciphers of different types +function cipherFactory(count: number): Record { + const ciphers: CipherView[] = []; + for (let i = 0; i < count; i++) { + const type = ((i % 4) + 1) as CipherType; + switch (type) { + case CipherType.Login: + ciphers.push({ + id: `${i}`, + type: CipherType.Login, + name: `Login ${i}`, + login: { + username: `username${i}`, + password: `password${i}`, + }, + } as CipherView); + break; + case CipherType.SecureNote: + ciphers.push({ + id: `${i}`, + type: CipherType.SecureNote, + name: `SecureNote ${i}`, + notes: `notes${i}`, + } as CipherView); + break; + case CipherType.Card: + ciphers.push({ + id: `${i}`, + type: CipherType.Card, + name: `Card ${i}`, + card: { + cardholderName: `cardholderName${i}`, + number: `number${i}`, + brand: `brand${i}`, + }, + } as CipherView); + break; + case CipherType.Identity: + ciphers.push({ + id: `${i}`, + type: CipherType.Identity, + name: `Identity ${i}`, + identity: { + firstName: `firstName${i}`, + lastName: `lastName${i}`, + }, + } as CipherView); + break; + } + } + return Object.fromEntries(ciphers.map((c) => [c.id, c])); +} diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts new file mode 100644 index 00000000000..52de117e6b5 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from "@angular/core"; +import { + combineLatest, + map, + Observable, + of, + shareReplay, + startWith, + Subject, + switchMap, +} from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +/** + * Service for managing the various item lists on the new Vault tab in the browser popup. + */ +@Injectable({ + providedIn: "root", +}) +export class VaultPopupItemsService { + private _refreshCurrentTab$ = new Subject(); + + /** + * Observable that contains the list of other cipher types that should be shown + * in the autofill section of the Vault tab. Depends on vault settings. + * @private + */ + private _otherAutoFillTypes$: Observable = combineLatest([ + this.vaultSettingsService.showCardsCurrentTab$, + this.vaultSettingsService.showIdentitiesCurrentTab$, + ]).pipe( + map(([showCards, showIdentities]) => { + return [ + ...(showCards ? [CipherType.Card] : []), + ...(showIdentities ? [CipherType.Identity] : []), + ]; + }), + ); + + /** + * Observable that contains the current tab to be considered for autofill. If there is no current tab + * or the popup is in a popout window, this will be null. + * @private + */ + private _currentAutofillTab$: Observable = this._refreshCurrentTab$.pipe( + startWith(null), + switchMap(async () => { + if (BrowserPopupUtils.inPopout(window)) { + return null; + } + return await BrowserApi.getTabFromCurrentWindow(); + }), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * Observable that contains the list of all decrypted ciphers. + * @private + */ + private _cipherList$: Observable = this.cipherService.cipherViews$.pipe( + map((ciphers) => Object.values(ciphers)), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities + * if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name. + * + * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. + */ + autoFillCiphers$: Observable = combineLatest([ + this._cipherList$, + this._otherAutoFillTypes$, + this._currentAutofillTab$, + ]).pipe( + switchMap(([ciphers, otherTypes, tab]) => { + if (!tab) { + return of([]); + } + return this.cipherService.filterCiphersForUrl(ciphers, tab.url, otherTypes); + }), + map((ciphers) => ciphers.sort(this.sortCiphersForAutofill.bind(this))), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of favorite ciphers that are not currently suggested for autofill. + * Ciphers are sorted by last used date, then by name. + */ + favoriteCiphers$: Observable = combineLatest([ + this.autoFillCiphers$, + this._cipherList$, + ]).pipe( + map(([autoFillCiphers, ciphers]) => + ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), + ), + map((ciphers) => + ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)), + ), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. + * Ciphers are sorted by name. + */ + remainingCiphers$: Observable = combineLatest([ + this.autoFillCiphers$, + this.favoriteCiphers$, + this._cipherList$, + ]).pipe( + map(([autoFillCiphers, favoriteCiphers, ciphers]) => + ciphers.filter( + (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), + ), + ), + map((ciphers) => ciphers.sort(this.cipherService.getLocaleSortingFunction())), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + /** + * Observable that indicates whether a filter is currently applied to the ciphers. + * @todo Implement filter/search functionality in PM-6824 and PM-6826. + */ + hasFilterApplied$: Observable = of(false); + + /** + * Observable that indicates whether autofill is allowed in the current context. + * Autofill is allowed when there is a current tab and the popup is not in a popout window. + */ + autofillAllowed$: Observable = this._currentAutofillTab$.pipe(map((tab) => !!tab)); + + /** + * Observable that indicates whether the user's vault is empty. + */ + emptyVault$: Observable = this._cipherList$.pipe(map((ciphers) => !ciphers.length)); + + /** + * Observable that indicates whether there are no ciphers to show with the current filter. + * @todo Implement filter/search functionality in PM-6824 and PM-6826. + */ + noFilteredResults$: Observable = of(false); + + constructor( + private cipherService: CipherService, + private vaultSettingsService: VaultSettingsService, + ) {} + + /** + * Re-fetch the current tab to trigger a re-evaluation of the autofill ciphers. + */ + refreshCurrentTab() { + this._refreshCurrentTab$.next(null); + } + + /** + * Sort function for ciphers to be used in the autofill section of the Vault tab. + * Sorts by type, then by last used date, and finally by name. + * @private + */ + private sortCiphersForAutofill(a: CipherView, b: CipherView): number { + const typeOrder: Record = { + [CipherType.Login]: 1, + [CipherType.Card]: 2, + [CipherType.Identity]: 3, + [CipherType.SecureNote]: 4, + }; + + // Compare types first + if (typeOrder[a.type] < typeOrder[b.type]) { + return -1; + } else if (typeOrder[a.type] > typeOrder[b.type]) { + return 1; + } + + // If types are the same, then sort by last used then name + return this.cipherService.sortCiphersByLastUsedThenName(a, b); + } +} diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index affbddf2b2d..be5c9ce4d96 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -1,6 +1,10 @@ /* eslint-disable no-undef, @typescript-eslint/no-var-requires */ const config = require("../../libs/components/tailwind.config.base"); -config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"]; +config.content = [ + "./src/**/*.{html,ts}", + "../../libs/components/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", +]; module.exports = config; diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index 8ed6a9b54be..ccc0af8fa4a 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -577,6 +577,17 @@ app-vault-view .box-footer { user-select: auto; } +/* override for vault icon in desktop */ +app-vault-icon > div { + display: flex; + justify-content: center; + align-items: center; + float: left; + height: 36px; + width: 34px; + margin-left: -5px; +} + /* tweak for inconsistent line heights in cipher view */ .box-footer button, .box-footer a { diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index affbddf2b2d..be5c9ce4d96 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -1,6 +1,10 @@ /* eslint-disable no-undef, @typescript-eslint/no-var-requires */ const config = require("../../libs/components/tailwind.config.base"); -config.content = ["./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}"]; +config.content = [ + "./src/**/*.{html,ts}", + "../../libs/components/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", +]; module.exports = config; diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index e80bf6a834d..08673c3f9a3 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -5,6 +5,7 @@ config.content = [ "./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", + "../../libs/angular/src/**/*.{html,ts}", "../../bitwarden_license/bit-web/src/**/*.{html,ts}", ]; diff --git a/libs/angular/src/vault/components/icon.component.html b/libs/angular/src/vault/components/icon.component.html index fd91d4095c8..976c6ea421d 100644 --- a/libs/angular/src/vault/components/icon.component.html +++ b/libs/angular/src/vault/components/icon.component.html @@ -1,9 +1,10 @@ -