From be51f1934a34360c5a54faa3d75c3f30ee1b64e9 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 7 May 2024 11:02:50 -0400 Subject: [PATCH] [AC-1121] Collections Add Access filter and badge (#8404) * added bit toggle group for add access filter to AC collections --- .../vault-collection-row.component.html | 12 ++- .../vault-collection-row.component.ts | 1 + .../vault-items/vault-items.component.html | 6 +- .../vault-items/vault-items.component.ts | 51 +++++++++ .../vault/core/views/collection-admin.view.ts | 29 +++++ .../app/vault/org-vault/vault.component.html | 16 +++ .../app/vault/org-vault/vault.component.ts | 100 ++++++++++++++++-- apps/web/src/locales/en/messages.json | 6 ++ .../vault/models/domain/collection.spec.ts | 1 + .../src/vault/models/view/collection.view.ts | 1 + 10 files changed, 214 insertions(+), 9 deletions(-) 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 d03b6dcc38..897d360b4b 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 8bf7779f88..4a9667f8b8 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 c63273fabd..ba69c038fb 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 2be84b0d24..cc217fc9ce 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 f815fccb21..af7b5059e5 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 243dedef93..4e06f7668c 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 4840003abd..f032e822f8 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 cd1cab8b42..4ee725be57 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 86766bdeac..f742b283bd 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) {