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";