diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 9b2472106dd..58c3e10e334 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -9,6 +9,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { CalloutModule, DialogModule } from "@bitwarden/components"; +import { AssignCollectionsComponent } from "@bitwarden/vault"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginModule } from "../auth/login/login.module"; @@ -55,6 +56,7 @@ import { SharedModule } from "./shared/shared.module"; DeleteAccountComponent, UserVerificationComponent, NavComponent, + AssignCollectionsComponent, VaultV2Component, ], declarations: [ diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 0cc466196fb..1685de7d8d4 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3812,5 +3812,139 @@ "message": "Learn more about SSH agent", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescriptionSingular": { + "message": "Only organization members with access to these collections will be able to see the item." + }, + "bulkCollectionAssignmentDialogDescriptionPlural": { + "message": "Only organization members with access to these collections will be able to see the items." + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "assign": { + "message": "Assign" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Only organization members with access to these collections will be able to see the items." + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2" + } + } + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "personalItemsTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + } + } + }, + "personalItemsWithOrgTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } + }, + "personalItemTransferWarningSingular": { + "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." + }, + "personalItemWithOrgTransferWarningSingular": { + "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "placeholders": { + "org": { + "content": "$1", + "example": "Organization name" + } + } + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "nothingSelected": { + "message": "You have not selected anything." + }, + "itemsMovedToOrg": { + "message": "Items moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + } + }, + "itemMovedToOrg": { + "message": "Item moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + } + }, + "movedItemsToOrg": { + "message": "Selected items moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + }, + "personalItemsTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + } + } + }, + "personalItemWithOrgTransferWarningSingular": { + "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "placeholders": { + "org": { + "content": "$1", + "example": "Organization name" + } + } + }, + "personalItemsWithOrgTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } + } } } diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html new file mode 100644 index 00000000000..4f5b6234ad9 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html @@ -0,0 +1,33 @@ + + + {{ "assignToCollections" | i18n }} + + {{ editableItemCount | pluralize: ("item" | i18n) : ("items" | i18n) }} + + + +
+ +
+ + + + + +
diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts new file mode 100644 index 00000000000..d81f1662c6c --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts @@ -0,0 +1,36 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + AssignCollectionsComponent, + CollectionAssignmentParams, + CollectionAssignmentResult, +} from "@bitwarden/vault"; + +@Component({ + standalone: true, + templateUrl: "./assign-collections-desktop.component.html", + imports: [AssignCollectionsComponent, PluralizePipe, DialogModule, ButtonModule, JslibModule], +}) +export class AssignCollectionsDesktopComponent { + protected editableItemCount: number; + + constructor( + @Inject(DIALOG_DATA) public params: CollectionAssignmentParams, + private dialogRef: DialogRef, + ) {} + + protected async onCollectionAssign(result: CollectionAssignmentResult) { + this.dialogRef.close(result); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + AssignCollectionsDesktopComponent, + config, + ); + } +} diff --git a/apps/desktop/src/vault/app/vault/assign-collections/index.ts b/apps/desktop/src/vault/app/vault/assign-collections/index.ts new file mode 100644 index 00000000000..1afe7128757 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/index.ts @@ -0,0 +1 @@ +export * from "./assign-collections-desktop.component"; diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 50e6bfb51c7..7a457fb3a44 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -9,7 +9,7 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs"; import { filter, map, take } from "rxjs/operators"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; @@ -19,6 +19,8 @@ import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/vie import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -57,6 +59,7 @@ import { CipherFormMode, CipherFormModule, CipherViewComponent, + CollectionAssignmentResult, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, DefaultCipherFormConfigService, @@ -69,6 +72,7 @@ import { DesktopCredentialGenerationService } from "../../../services/desktop-ci import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { AssignCollectionsDesktopComponent } from "./assign-collections"; import { ItemFooterComponent } from "./item-footer.component"; import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; @@ -142,6 +146,11 @@ export class VaultV2Component implements OnInit, OnDestroy { config: CipherFormConfig | null = null; isSubmitting = false; + private organizations$: Observable = this.accountService.activeAccount$.pipe( + map((a) => a?.id), + switchMap((id) => this.organizationService.organizations$(id)), + ); + protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( filter((account): account is Account => !!account), switchMap((account) => @@ -151,6 +160,8 @@ export class VaultV2Component implements OnInit, OnDestroy { private modal: ModalRef | null = null; private componentIsDestroyed$ = new Subject(); + private allOrganizations: Organization[] = []; + private allCollections: CollectionView[] = []; constructor( private route: ActivatedRoute, @@ -176,6 +187,7 @@ export class VaultV2Component implements OnInit, OnDestroy { private formConfigService: CipherFormConfigService, private premiumUpgradePromptService: PremiumUpgradePromptService, private collectionService: CollectionService, + private organizationService: OrganizationService, private folderService: FolderService, ) {} @@ -312,6 +324,16 @@ export class VaultV2Component implements OnInit, OnDestroy { }); }); } + + this.organizations$.pipe(takeUntil(this.componentIsDestroyed$)).subscribe((orgs) => { + this.allOrganizations = orgs; + }); + + this.collectionService.decryptedCollections$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((collections) => { + this.allCollections = collections; + }); } ngOnDestroy() { @@ -420,6 +442,16 @@ export class VaultV2Component implements OnInit, OnDestroy { }, }); } + + if (cipher.canAssignToCollections) { + menu.push({ + label: this.i18nService.t("assignToCollections"), + click: () => + this.functionWithChangeDetection(async () => { + await this.shareCipher(cipher); + }), + }); + } } switch (cipher.type) { @@ -531,6 +563,36 @@ export class VaultV2Component implements OnInit, OnDestroy { await this.go().catch(() => {}); } + async shareCipher(cipher: CipherView) { + if (!cipher) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (!(await this.passwordReprompt(cipher))) { + return; + } + + const availableCollections = this.getAvailableCollections(cipher); + + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: { + ciphers: [cipher], + organizationId: cipher.organizationId as OrganizationId, + availableCollections, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + await this.savedCipher(cipher); + } + } + async addCipher(type: CipherType) { if (this.action === "add") { return; @@ -603,6 +665,16 @@ export class VaultV2Component implements OnInit, OnDestroy { await this.go().catch(() => {}); } + private getAvailableCollections(cipher: CipherView): CollectionView[] { + const orgId = cipher.organizationId; + if (!orgId || orgId === "MyVault") { + return []; + } + + const organization = this.allOrganizations.find((o) => o.id === orgId); + return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); + } + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { if (vaultFilter.status === "favorites") { return "searchFavorites";