diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html
index d03b6dcc385..897d360b4be 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html
+++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html
@@ -20,7 +20,7 @@
bitLink
[disabled]="disabled"
type="button"
- class="tw-w-full tw-truncate tw-text-start tw-leading-snug"
+ class="tw-flex tw-w-full tw-text-start tw-leading-snug"
linkType="secondary"
title="{{ 'viewCollectionWithName' | i18n: collection.name }}"
[routerLink]="[]"
@@ -28,7 +28,15 @@
queryParamsHandling="merge"
appStopProp
>
- {{ collection.name }}
+ {{ collection.name }}
+
+ {{ "addAccess" | i18n }}
+
diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts
index 8bf7779f886..4a9667f8b8f 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts
@@ -21,6 +21,7 @@ import { RowHeightClass } from "./vault-items.component";
})
export class VaultCollectionRowComponent {
protected RowHeightClass = RowHeightClass;
+ protected Unassigned = "unassigned";
@Input() disabled: boolean;
@Input() collection: CollectionView;
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html
index c63273fabd3..ba69c038fb3 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html
@@ -99,8 +99,12 @@
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
>
+
| o.id === collection.organizationId);
+
+ if (this.flexibleCollectionsV1Enabled) {
+ //Custom user without edit access should not see the Edit option unless that user has "Can Manage" access to a collection
+ if (
+ !collection.manage &&
+ organization?.type === OrganizationUserType.Custom &&
+ !organization?.permissions.editAnyCollection
+ ) {
+ return false;
+ }
+ //Owner/Admin and Custom Users with Edit can see Edit and Access of Orphaned Collections
+ if (
+ collection.addAccess &&
+ collection.id !== Unassigned &&
+ ((organization?.type === OrganizationUserType.Custom &&
+ organization?.permissions.editAnyCollection) ||
+ organization.isAdmin ||
+ organization.isOwner)
+ ) {
+ return true;
+ }
+ }
return collection.canEdit(organization, this.flexibleCollectionsV1Enabled);
}
@@ -111,6 +136,32 @@ export class VaultItemsComponent {
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
+
+ if (this.flexibleCollectionsV1Enabled) {
+ //Custom user with only edit access should not see the Delete button for orphaned collections
+ if (
+ collection.addAccess &&
+ organization?.type === OrganizationUserType.Custom &&
+ !organization?.permissions.deleteAnyCollection &&
+ organization?.permissions.editAnyCollection
+ ) {
+ return false;
+ }
+
+ // Owner/Admin with no access to a collection will not see Delete
+ if (
+ !collection.assigned &&
+ !collection.addAccess &&
+ (organization.isAdmin || organization.isOwner) &&
+ !(
+ organization?.type === OrganizationUserType.Custom &&
+ organization?.permissions.deleteAnyCollection
+ )
+ ) {
+ return false;
+ }
+ }
+
return collection.canDelete(organization);
}
diff --git a/apps/web/src/app/vault/core/views/collection-admin.view.ts b/apps/web/src/app/vault/core/views/collection-admin.view.ts
index 2be84b0d246..cc217fc9cef 100644
--- a/apps/web/src/app/vault/core/views/collection-admin.view.ts
+++ b/apps/web/src/app/vault/core/views/collection-admin.view.ts
@@ -1,3 +1,4 @@
+import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@@ -7,6 +8,7 @@ import { CollectionAccessSelectionView } from "../../../admin-console/organizati
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
+ addAccess: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
@@ -31,6 +33,33 @@ export class CollectionAdminView extends CollectionView {
this.assigned = response.assigned;
}
+ groupsCanManage() {
+ if (this.groups.length === 0) {
+ return this.groups;
+ }
+
+ const returnedGroups = this.groups.filter((group) => {
+ if (group.manage) {
+ return group;
+ }
+ });
+ return returnedGroups;
+ }
+
+ usersCanManage(revokedUsers: OrganizationUserUserDetailsResponse[]) {
+ if (this.users.length === 0) {
+ return this.users;
+ }
+
+ const returnedUsers = this.users.filter((user) => {
+ const isRevoked = revokedUsers.some((revoked) => revoked.id === user.id);
+ if (user.manage && !isRevoked) {
+ return user;
+ }
+ });
+ return returnedUsers;
+ }
+
/**
* Whether the current user can edit the collection, including user and group access
*/
diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html
index f815fccb213..af7b5059e52 100644
--- a/apps/web/src/app/vault/org-vault/vault.component.html
+++ b/apps/web/src/app/vault/org-vault/vault.component.html
@@ -26,6 +26,20 @@
+
+
+ {{ "all" | i18n }}
+
+
+
+ {{ "addAccess" | i18n }}
+
+
{{ trashCleanupWarning }}
@@ -54,6 +68,8 @@
[showBulkAddToCollections]="organization?.flexibleCollections"
[viewingOrgVault]="true"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
+ [addAccessStatus]="addAccessStatus$ | async"
+ [addAccessToggle]="showAddAccessToggle"
>
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 243dedef930..4e06f7668c9 100644
--- a/apps/web/src/app/vault/org-vault/vault.component.ts
+++ b/apps/web/src/app/vault/org-vault/vault.component.ts
@@ -36,6 +36,9 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
+import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
+import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -102,6 +105,11 @@ import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
const BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200;
+enum AddAccessStatusType {
+ All = 0,
+ AddAccess = 1,
+}
+
@Component({
selector: "app-org-vault",
templateUrl: "vault.component.html",
@@ -122,6 +130,7 @@ export class VaultComponent implements OnInit, OnDestroy {
trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter();
+ protected showAddAccessToggle = false;
protected noItemIcon = Icons.Search;
protected performingInitialLoad = true;
protected refreshing = false;
@@ -149,10 +158,12 @@ export class VaultComponent implements OnInit, OnDestroy {
protected get flexibleCollectionsV1Enabled(): boolean {
return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections;
}
+ protected orgRevokedUsers: OrganizationUserUserDetailsResponse[];
private searchText$ = new Subject();
private refresh$ = new BehaviorSubject(null);
private destroy$ = new Subject();
+ protected addAccessStatus$ = new BehaviorSubject(0);
constructor(
private route: ActivatedRoute,
@@ -181,6 +192,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService,
private apiService: ApiService,
private collectionService: CollectionService,
+ private organizationUserService: OrganizationUserService,
protected configService: ConfigService,
) {}
@@ -241,6 +253,11 @@ export class VaultComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => {
this.activeFilter = activeFilter;
+
+ // watch the active filters. Only show toggle when viewing the collections filter
+ if (!this.activeFilter.collectionId) {
+ this.showAddAccessToggle = false;
+ }
});
this.searchText$
@@ -309,6 +326,10 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers$ = organization$.pipe(
concatMap(async (organization) => {
+ // If user swaps organization reset the addAccessToggle
+ if (!this.showAddAccessToggle || organization) {
+ this.addAccessToggle(0);
+ }
let ciphers;
if (this.flexibleCollectionsV1Enabled) {
@@ -348,9 +369,21 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }),
);
- const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe(
+ // This will be passed into the usersCanManage call
+ this.orgRevokedUsers = (
+ await this.organizationUserService.getAllUsers(await firstValueFrom(organizationId$))
+ ).data.filter((user: OrganizationUserUserDetailsResponse) => {
+ return user.status === -1;
+ });
+
+ const collections$ = combineLatest([
+ nestedCollections$,
+ filter$,
+ this.currentSearchText$,
+ this.addAccessStatus$,
+ ]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined),
- concatMap(async ([collections, filter, searchText]) => {
+ concatMap(async ([collections, filter, searchText, addAccessStatus]) => {
if (
filter.collectionId === Unassigned ||
(filter.collectionId === undefined && filter.type !== undefined)
@@ -358,26 +391,30 @@ export class VaultComponent implements OnInit, OnDestroy {
return [];
}
+ this.showAddAccessToggle = false;
let collectionsToReturn = [];
if (filter.collectionId === undefined || filter.collectionId === All) {
- collectionsToReturn = collections.map((c) => c.node);
+ collectionsToReturn = await this.addAccessCollectionsMap(collections);
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId,
);
- collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
+ collectionsToReturn = await this.addAccessCollectionsMap(selectedCollection?.children);
}
if (await this.searchService.isSearchable(searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,
- (collection) => collection.name,
- (collection) => collection.id,
+ (collection: CollectionAdminView) => collection.name,
+ (collection: CollectionAdminView) => collection.id,
);
}
+ if (addAccessStatus === 1 && this.showAddAccessToggle) {
+ collectionsToReturn = collectionsToReturn.filter((c: any) => c.addAccess);
+ }
return collectionsToReturn;
}),
takeUntil(this.destroy$),
@@ -586,6 +623,57 @@ export class VaultComponent implements OnInit, OnDestroy {
);
}
+ // Update the list of collections to see if any collection is orphaned
+ // and will receive the addAccess badge / be filterable by the user
+ async addAccessCollectionsMap(collections: TreeNode[]) {
+ let mappedCollections;
+ const { type, allowAdminAccessToAllCollectionItems, permissions } = this.organization;
+
+ const canEditCiphersCheck =
+ this._flexibleCollectionsV1FlagEnabled &&
+ !this.organization.canEditAllCiphers(this._flexibleCollectionsV1FlagEnabled);
+
+ // This custom type check will show addAccess badge for
+ // Custom users with canEdit access AND owner/admin manage access setting is OFF
+ const customUserCheck =
+ this._flexibleCollectionsV1FlagEnabled &&
+ !allowAdminAccessToAllCollectionItems &&
+ type === OrganizationUserType.Custom &&
+ permissions.editAnyCollection;
+
+ // If Custom user has Delete Only access they will not see Add Access toggle
+ const customUserOnlyDelete =
+ this.flexibleCollectionsV1Enabled &&
+ type === OrganizationUserType.Custom &&
+ permissions.deleteAnyCollection &&
+ !permissions.editAnyCollection;
+
+ if (!customUserOnlyDelete && (canEditCiphersCheck || customUserCheck)) {
+ mappedCollections = collections.map((c: TreeNode) => {
+ const groupsCanManage = c.node.groupsCanManage();
+ const usersCanManage = c.node.usersCanManage(this.orgRevokedUsers);
+ if (
+ groupsCanManage.length === 0 &&
+ usersCanManage.length === 0 &&
+ c.node.id !== Unassigned
+ ) {
+ c.node.addAccess = true;
+ this.showAddAccessToggle = true;
+ } else {
+ c.node.addAccess = false;
+ }
+ return c.node;
+ });
+ } else {
+ mappedCollections = collections.map((c: TreeNode) => c.node);
+ }
+ return mappedCollections;
+ }
+
+ addAccessToggle(e: any) {
+ this.addAccessStatus$.next(e);
+ }
+
get loading() {
return this.refreshing || this.processingEvent;
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 4840003abdf..f032e822f86 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -2788,6 +2788,12 @@
"all": {
"message": "All"
},
+ "addAccess": {
+ "message": "Add Access"
+ },
+ "addAccessFilter": {
+ "message": "Add Access Filter"
+ },
"refresh": {
"message": "Refresh"
},
diff --git a/libs/common/src/vault/models/domain/collection.spec.ts b/libs/common/src/vault/models/domain/collection.spec.ts
index cd1cab8b422..4ee725be57f 100644
--- a/libs/common/src/vault/models/domain/collection.spec.ts
+++ b/libs/common/src/vault/models/domain/collection.spec.ts
@@ -61,6 +61,7 @@ describe("Collection", () => {
const view = await collection.decrypt();
expect(view).toEqual({
+ addAccess: false,
externalId: "extId",
hidePasswords: false,
id: "id",
diff --git a/libs/common/src/vault/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts
index 86766bdeac6..f742b283bda 100644
--- a/libs/common/src/vault/models/view/collection.view.ts
+++ b/libs/common/src/vault/models/view/collection.view.ts
@@ -17,6 +17,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null;
hidePasswords: boolean = null;
manage: boolean = null;
+ addAccess: boolean = false;
assigned: boolean = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) {