1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-31 17:57:43 +01:00

[AC-1121] Collections Add Access filter and badge (#8404)

* added bit toggle group for add access filter to AC collections
This commit is contained in:
Jason Ng 2024-05-07 11:02:50 -04:00 committed by GitHub
parent c051412d41
commit be51f1934a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 214 additions and 9 deletions

View File

@ -20,7 +20,7 @@
bitLink bitLink
[disabled]="disabled" [disabled]="disabled"
type="button" 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" linkType="secondary"
title="{{ 'viewCollectionWithName' | i18n: collection.name }}" title="{{ 'viewCollectionWithName' | i18n: collection.name }}"
[routerLink]="[]" [routerLink]="[]"
@ -28,7 +28,15 @@
queryParamsHandling="merge" queryParamsHandling="merge"
appStopProp appStopProp
> >
{{ collection.name }} <span class="tw-truncate tw-mr-1">{{ collection.name }}</span>
<div>
<span
*ngIf="collection.addAccess && collection.id !== Unassigned"
bitBadge
variant="warning"
>{{ "addAccess" | i18n }}</span
>
</div>
</button> </button>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner"> <td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner">

View File

@ -21,6 +21,7 @@ import { RowHeightClass } from "./vault-items.component";
}) })
export class VaultCollectionRowComponent { export class VaultCollectionRowComponent {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
protected Unassigned = "unassigned";
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() collection: CollectionView; @Input() collection: CollectionView;

View File

@ -99,8 +99,12 @@
(checkedToggled)="selection.toggle(item)" (checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)" (onEvent)="event($event)"
></tr> ></tr>
<!--
addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess
-->
<tr <tr
*ngIf="item.cipher" *ngIf="item.cipher && (!addAccessToggle || (addAccessToggle && addAccessStatus !== 1))"
bitRow bitRow
appVaultCipherRow appVaultCipherRow
alignContent="middle" alignContent="middle"

View File

@ -1,6 +1,7 @@
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -45,6 +46,8 @@ export class VaultItemsComponent {
@Input() showPermissionsColumn = false; @Input() showPermissionsColumn = false;
@Input() viewingOrgVault: boolean; @Input() viewingOrgVault: boolean;
@Input({ required: true }) flexibleCollectionsV1Enabled = false; @Input({ required: true }) flexibleCollectionsV1Enabled = false;
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
private _ciphers?: CipherView[] = []; private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] { @Input() get ciphers(): CipherView[] {
@ -101,6 +104,28 @@ export class VaultItemsComponent {
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); const organization = this.allOrganizations.find((o) => 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); return collection.canEdit(organization, this.flexibleCollectionsV1Enabled);
} }
@ -111,6 +136,32 @@ export class VaultItemsComponent {
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); 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); return collection.canDelete(organization);
} }

View File

@ -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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response"; import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; 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 { export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = []; groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = []; users: CollectionAccessSelectionView[] = [];
addAccess: boolean;
/** /**
* Flag indicating the user has been explicitly assigned to this Collection * Flag indicating the user has been explicitly assigned to this Collection
@ -31,6 +33,33 @@ export class CollectionAdminView extends CollectionView {
this.assigned = response.assigned; 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 * Whether the current user can edit the collection, including user and group access
*/ */

View File

@ -26,6 +26,20 @@
</div> </div>
</div> </div>
<div class="col-9"> <div class="col-9">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle"> <app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle">
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</app-callout> </app-callout>
@ -54,6 +68,8 @@
[showBulkAddToCollections]="organization?.flexibleCollections" [showBulkAddToCollections]="organization?.flexibleCollections"
[viewingOrgVault]="true" [viewingOrgVault]="true"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled" [flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
> >
</app-vault-items> </app-vault-items>
<ng-container *ngIf="!flexibleCollectionsV1Enabled"> <ng-container *ngIf="!flexibleCollectionsV1Enabled">

View File

@ -36,6 +36,9 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200; const SearchTextDebounceInterval = 200;
enum AddAccessStatusType {
All = 0,
AddAccess = 1,
}
@Component({ @Component({
selector: "app-org-vault", selector: "app-org-vault",
templateUrl: "vault.component.html", templateUrl: "vault.component.html",
@ -122,6 +130,7 @@ export class VaultComponent implements OnInit, OnDestroy {
trashCleanupWarning: string = null; trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter(); activeFilter: VaultFilter = new VaultFilter();
protected showAddAccessToggle = false;
protected noItemIcon = Icons.Search; protected noItemIcon = Icons.Search;
protected performingInitialLoad = true; protected performingInitialLoad = true;
protected refreshing = false; protected refreshing = false;
@ -149,10 +158,12 @@ export class VaultComponent implements OnInit, OnDestroy {
protected get flexibleCollectionsV1Enabled(): boolean { protected get flexibleCollectionsV1Enabled(): boolean {
return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections; return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections;
} }
protected orgRevokedUsers: OrganizationUserUserDetailsResponse[];
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -181,6 +192,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService, private totpService: TotpService,
private apiService: ApiService, private apiService: ApiService,
private collectionService: CollectionService, private collectionService: CollectionService,
private organizationUserService: OrganizationUserService,
protected configService: ConfigService, protected configService: ConfigService,
) {} ) {}
@ -241,6 +253,11 @@ export class VaultComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => { .subscribe((activeFilter) => {
this.activeFilter = 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$ this.searchText$
@ -309,6 +326,10 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers$ = organization$.pipe( const allCiphers$ = organization$.pipe(
concatMap(async (organization) => { concatMap(async (organization) => {
// If user swaps organization reset the addAccessToggle
if (!this.showAddAccessToggle || organization) {
this.addAccessToggle(0);
}
let ciphers; let ciphers;
if (this.flexibleCollectionsV1Enabled) { if (this.flexibleCollectionsV1Enabled) {
@ -348,9 +369,21 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }), 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), filter(([collections, filter]) => collections != undefined && filter != undefined),
concatMap(async ([collections, filter, searchText]) => { concatMap(async ([collections, filter, searchText, addAccessStatus]) => {
if ( if (
filter.collectionId === Unassigned || filter.collectionId === Unassigned ||
(filter.collectionId === undefined && filter.type !== undefined) (filter.collectionId === undefined && filter.type !== undefined)
@ -358,26 +391,30 @@ export class VaultComponent implements OnInit, OnDestroy {
return []; return [];
} }
this.showAddAccessToggle = false;
let collectionsToReturn = []; let collectionsToReturn = [];
if (filter.collectionId === undefined || filter.collectionId === All) { if (filter.collectionId === undefined || filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node); collectionsToReturn = await this.addAccessCollectionsMap(collections);
} else { } else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections, collections,
filter.collectionId, filter.collectionId,
); );
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; collectionsToReturn = await this.addAccessCollectionsMap(selectedCollection?.children);
} }
if (await this.searchService.isSearchable(searchText)) { if (await this.searchService.isSearchable(searchText)) {
collectionsToReturn = this.searchPipe.transform( collectionsToReturn = this.searchPipe.transform(
collectionsToReturn, collectionsToReturn,
searchText, searchText,
(collection) => collection.name, (collection: CollectionAdminView) => collection.name,
(collection) => collection.id, (collection: CollectionAdminView) => collection.id,
); );
} }
if (addAccessStatus === 1 && this.showAddAccessToggle) {
collectionsToReturn = collectionsToReturn.filter((c: any) => c.addAccess);
}
return collectionsToReturn; return collectionsToReturn;
}), }),
takeUntil(this.destroy$), 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<CollectionAdminView>[]) {
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<CollectionAdminView>) => {
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<CollectionAdminView>) => c.node);
}
return mappedCollections;
}
addAccessToggle(e: any) {
this.addAccessStatus$.next(e);
}
get loading() { get loading() {
return this.refreshing || this.processingEvent; return this.refreshing || this.processingEvent;
} }

View File

@ -2788,6 +2788,12 @@
"all": { "all": {
"message": "All" "message": "All"
}, },
"addAccess": {
"message": "Add Access"
},
"addAccessFilter": {
"message": "Add Access Filter"
},
"refresh": { "refresh": {
"message": "Refresh" "message": "Refresh"
}, },

View File

@ -61,6 +61,7 @@ describe("Collection", () => {
const view = await collection.decrypt(); const view = await collection.decrypt();
expect(view).toEqual({ expect(view).toEqual({
addAccess: false,
externalId: "extId", externalId: "extId",
hidePasswords: false, hidePasswords: false,
id: "id", id: "id",

View File

@ -17,6 +17,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
manage: boolean = null; manage: boolean = null;
addAccess: boolean = false;
assigned: boolean = null; assigned: boolean = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) { constructor(c?: Collection | CollectionAccessDetailsResponse) {