1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[AC-2086] Limit admin access - Collection Modal (#8335)

* feat: add view collection string, update button text, refs AC-2086

* feat: remove canEdit from Restricted Collection Access component, refs AC-2086

* feat: add view collection clicked flow, refs AC-2086

* fix: revert accidental svg icon changes, refs AC-2086

* feat: add input for access selector to hide multi select, refs AC-2086

* feat: apply readonly/disabled changes to access dialog, refs AC-2086

* fix: messages file conflict, refs AC-2086

* feat: apply disabled state to access selector, refs AC-2086

* fix: formatting, refs AC-2086

* fix: permission mode read only relocate, refs AC-2086

* fix: conform readonly casing, refs AC-2086
This commit is contained in:
Vincent Salucci
2024-04-08 13:24:27 -05:00
committed by GitHub
parent 7064b595da
commit 0c291bf79b
8 changed files with 145 additions and 37 deletions

View File

@@ -22,7 +22,7 @@
</select> </select>
</bit-form-field> </bit-form-field>
<bit-form-field class="tw-grow"> <bit-form-field class="tw-grow" *ngIf="!disabled">
<bit-label>{{ selectorLabelText }}</bit-label> <bit-label>{{ selectorLabelText }}</bit-label>
<bit-multi-select <bit-multi-select
class="tw-w-full" class="tw-w-full"
@@ -120,7 +120,7 @@
</div> </div>
<div <div
*ngIf="item.readonly" *ngIf="item.readonly || disabled"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted" class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n" [title]="permissionLabelId(item.readonlyPermission) | i18n"
> >
@@ -139,7 +139,7 @@
<td bitCell class="tw-text-right"> <td bitCell class="tw-text-right">
<button <button
*ngIf="!item.readonly" *ngIf="!disabled && !item.readonly"
type="button" type="button"
bitIconButton="bwi-close" bitIconButton="bwi-close"
buttonType="muted" buttonType="muted"

View File

@@ -121,6 +121,13 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
protected permissionList: Permission[]; protected permissionList: Permission[];
protected initialPermission = CollectionPermission.View; protected initialPermission = CollectionPermission.View;
/**
* When disabled, the access selector will make the assumption that a readonly state is desired.
* The PermissionMode will be set to Readonly
* The Multi-Select control will be hidden
* The delete action on each row item will be hidden
* The readonly permission label/property needs to configured on the access item views being passed into the component
*/
disabled: boolean; disabled: boolean;
/** /**
@@ -225,6 +232,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
// Keep the internal FormGroup in sync // Keep the internal FormGroup in sync
if (this.disabled) { if (this.disabled) {
this.permissionMode = PermissionMode.Readonly;
this.formGroup.disable(); this.formGroup.disable();
} else { } else {
this.formGroup.enable(); this.formGroup.enable();

View File

@@ -65,17 +65,22 @@
</bit-tab> </bit-tab>
<bit-tab label="{{ 'access' | i18n }}"> <bit-tab label="{{ 'access' | i18n }}">
<div class="tw-mb-3" *ngIf="organization.flexibleCollections"> <div class="tw-mb-3" *ngIf="organization.flexibleCollections">
<span *ngIf="organization.useGroups">{{ "grantCollectionAccess" | i18n }}</span> <ng-container *ngIf="dialogReadonly">
<span *ngIf="!organization.useGroups">{{ <span>{{ "readOnlyCollectionAccess" | i18n }}</span>
"grantCollectionAccessMembersOnly" | i18n </ng-container>
}}</span> <ng-container *ngIf="!dialogReadonly">
<span <span *ngIf="organization.useGroups">{{ "grantCollectionAccess" | i18n }}</span>
*ngIf=" <span *ngIf="!organization.useGroups">{{
(flexibleCollectionsV1Enabled$ | async) && "grantCollectionAccessMembersOnly" | i18n
organization.allowAdminAccessToAllCollectionItems }}</span>
" <span
>{{ " " + ("adminCollectionAccess" | i18n) }}</span *ngIf="
> (flexibleCollectionsV1Enabled$ | async) &&
organization.allowAdminAccessToAllCollectionItems
"
>{{ " " + ("adminCollectionAccess" | i18n) }}</span
>
</ng-container>
</div> </div>
<div <div
class="tw-mb-3 tw-text-danger" class="tw-mb-3 tw-text-danger"
@@ -85,7 +90,7 @@
</div> </div>
<bit-access-selector <bit-access-selector
*ngIf="organization.useGroups" *ngIf="organization.useGroups"
[permissionMode]="PermissionMode.Edit" [permissionMode]="dialogReadonly ? PermissionMode.Readonly : PermissionMode.Edit"
formControlName="access" formControlName="access"
[items]="accessItems" [items]="accessItems"
[columnHeader]="'groupSlashMemberColumnHeader' | i18n" [columnHeader]="'groupSlashMemberColumnHeader' | i18n"
@@ -96,7 +101,7 @@
></bit-access-selector> ></bit-access-selector>
<bit-access-selector <bit-access-selector
*ngIf="!organization.useGroups" *ngIf="!organization.useGroups"
[permissionMode]="PermissionMode.Edit" [permissionMode]="dialogReadonly ? PermissionMode.Readonly : PermissionMode.Edit"
formControlName="access" formControlName="access"
[items]="accessItems" [items]="accessItems"
[columnHeader]="'memberColumnHeader' | i18n" [columnHeader]="'memberColumnHeader' | i18n"
@@ -108,7 +113,13 @@
</bit-tab-group> </bit-tab-group>
</div> </div>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading"> <button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="loading || dialogReadonly"
>
{{ "save" | i18n }} {{ "save" | i18n }}
</button> </button>
<button <button

View File

@@ -16,6 +16,7 @@ import { first } from "rxjs/operators";
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 { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses/organization-user.response";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -27,7 +28,11 @@ import { CollectionResponse } from "@bitwarden/common/vault/models/response/coll
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BitValidators, DialogService } from "@bitwarden/components"; import { BitValidators, DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../admin-console/organizations/core"; import {
GroupService,
GroupView,
CollectionAccessSelectionView,
} from "../../../admin-console/organizations/core";
import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component"; import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component";
import { import {
AccessItemType, AccessItemType,
@@ -36,8 +41,6 @@ import {
CollectionPermission, CollectionPermission,
convertToPermission, convertToPermission,
convertToSelectionView, convertToSelectionView,
mapGroupToAccessItemView,
mapUserToAccessItemView,
} from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { CollectionAdminService } from "../../core/collection-admin.service"; import { CollectionAdminService } from "../../core/collection-admin.service";
import { CollectionAdminView } from "../../core/views/collection-admin.view"; import { CollectionAdminView } from "../../core/views/collection-admin.view";
@@ -54,6 +57,7 @@ export interface CollectionDialogParams {
parentCollectionId?: string; parentCollectionId?: string;
showOrgSelector?: boolean; showOrgSelector?: boolean;
collectionIds?: string[]; collectionIds?: string[];
readonly?: boolean;
} }
export interface CollectionDialogResult { export interface CollectionDialogResult {
@@ -158,7 +162,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
? from(this.collectionAdminService.get(orgId, this.params.collectionId)) ? from(this.collectionAdminService.get(orgId, this.params.collectionId))
: of(null), : of(null),
groups: groups$, groups: groups$,
users: this.organizationUserService.getAllUsers(orgId), // Collection(s) needed to map readonlypermission for (potential) access selector disabled state
users: this.organizationUserService.getAllUsers(orgId, { includeCollections: true }),
collection: this.params.collectionId collection: this.params.collectionId
? this.collectionService.get(this.params.collectionId) ? this.collectionService.get(this.params.collectionId)
: of(null), : of(null),
@@ -177,8 +182,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}) => { }) => {
this.organization = organization; this.organization = organization;
this.accessItems = [].concat( this.accessItems = [].concat(
groups.map(mapGroupToAccessItemView), groups.map((group) => mapGroupToAccessItemView(group, this.collectionId)),
users.data.map(mapUserToAccessItemView), users.data.map((user) => mapUserToAccessItemView(user, this.collectionId)),
); );
// Force change detection to update the access selector's items // Force change detection to update the access selector's items
@@ -209,7 +214,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
access: accessSelections, access: accessSelections,
}); });
this.collection.manage = collection?.manage ?? false; // Get manage flag from sync data collection this.collection.manage = collection?.manage ?? false; // Get manage flag from sync data collection
this.showDeleteButton = this.collection.canDelete(organization); this.showDeleteButton = !this.dialogReadonly && this.collection.canDelete(organization);
} else { } else {
this.nestOptions = collections; this.nestOptions = collections;
const parent = collections.find((c) => c.id === this.params.parentCollectionId); const parent = collections.find((c) => c.id === this.params.parentCollectionId);
@@ -244,6 +249,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
} }
this.formGroup.controls.access.updateValueAndValidity(); this.formGroup.controls.access.updateValueAndValidity();
this.handleFormGroupReadonly(this.dialogReadonly);
this.loading = false; this.loading = false;
}, },
); );
@@ -257,11 +264,20 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
return this.params.collectionId != undefined; return this.params.collectionId != undefined;
} }
protected get dialogReadonly() {
return this.params.readonly === true;
}
protected async cancel() { protected async cancel() {
this.close(CollectionDialogAction.Canceled); this.close(CollectionDialogAction.Canceled);
} }
protected submit = async () => { protected submit = async () => {
// Saving a collection is prohibited while in read only mode
if (this.dialogReadonly) {
return;
}
this.formGroup.markAllAsTouched(); this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) { if (this.formGroup.invalid) {
@@ -316,6 +332,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}; };
protected delete = async () => { protected delete = async () => {
// Deleting a collection is prohibited while in read only mode
if (this.dialogReadonly) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: this.collection?.name, title: this.collection?.name,
content: { key: "deleteCollectionConfirmation" }, content: { key: "deleteCollectionConfirmation" },
@@ -342,6 +363,20 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
private handleFormGroupReadonly(readonly: boolean) {
if (readonly) {
this.formGroup.controls.name.disable();
this.formGroup.controls.externalId.disable();
this.formGroup.controls.parent.disable();
this.formGroup.controls.access.disable();
} else {
this.formGroup.controls.name.enable();
this.formGroup.controls.externalId.enable();
this.formGroup.controls.parent.enable();
this.formGroup.controls.access.enable();
}
}
private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) { private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) {
this.dialogRef.close({ action, collection } as CollectionDialogResult); this.dialogRef.close({ action, collection } as CollectionDialogResult);
} }
@@ -383,6 +418,50 @@ function validateCanManagePermission(control: AbstractControl) {
return hasManagePermission ? null : { managePermissionRequired: true }; return hasManagePermission ? null : { managePermissionRequired: true };
} }
/**
*
* @param group Current group being used to translate object into AccessItemView
* @param collectionId Current collection being viewed/edited
* @returns AccessItemView customized to set a readonlyPermission to be displayed if the access selector is in a disabled state
*/
function mapGroupToAccessItemView(group: GroupView, collectionId: string): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
readonlyPermission: convertToPermission(group.collections.find((gc) => gc.id == collectionId)),
};
}
/**
*
* @param user Current user being used to translate object into AccessItemView
* @param collectionId Current collection being viewed/edited
* @returns AccessItemView customized to set a readonlyPermission to be displayed if the access selector is in a disabled state
*/
function mapUserToAccessItemView(
user: OrganizationUserUserDetailsResponse,
collectionId: string,
): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
readonlyPermission: convertToPermission(
new CollectionAccessSelectionView(user.collections.find((uc) => uc.id == collectionId)),
),
};
}
/** /**
* Strongly typed helper to open a CollectionDialog * Strongly typed helper to open a CollectionDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog * @param dialogService Instance of the dialog service that will be used to open the dialog

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Output } from "@angular/core";
import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components"; import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
@@ -16,21 +16,18 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height=
template: `<bit-no-items [icon]="icon" class="tw-mt-2 tw-block"> template: `<bit-no-items [icon]="icon" class="tw-mt-2 tw-block">
<span slot="title" class="tw-mt-4 tw-block">{{ "collectionAccessRestricted" | i18n }}</span> <span slot="title" class="tw-mt-4 tw-block">{{ "collectionAccessRestricted" | i18n }}</span>
<button <button
*ngIf="canEdit"
slot="button" slot="button"
bitButton bitButton
(click)="editInfoClicked.emit()" (click)="viewCollectionClicked.emit()"
buttonType="secondary" buttonType="secondary"
type="button" type="button"
> >
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editInfo" | i18n }} <i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "viewCollection" | i18n }}
</button> </button>
</bit-no-items>`, </bit-no-items>`,
}) })
export class CollectionAccessRestrictedComponent { export class CollectionAccessRestrictedComponent {
protected icon = icon; protected icon = icon;
@Input() canEdit = false; @Output() viewCollectionClicked = new EventEmitter<void>();
@Output() editInfoClicked = new EventEmitter<void>();
} }

View File

@@ -99,11 +99,9 @@
</bit-no-items> </bit-no-items>
<collection-access-restricted <collection-access-restricted
*ngIf="showCollectionAccessRestricted" *ngIf="showCollectionAccessRestricted"
[canEdit]=" (viewCollectionClicked)="
selectedCollection != null && editCollection(selectedCollection.node, CollectionDialogTabType.Info, true)
selectedCollection.node.canEdit(organization, flexibleCollectionsV1Enabled)
" "
(editInfoClicked)="editCollection(selectedCollection.node, CollectionDialogTabType.Info)"
> >
</collection-access-restricted> </collection-access-restricted>
</ng-container> </ng-container>

View File

@@ -1058,9 +1058,18 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise<void> { async editCollection(
c: CollectionView,
tab: CollectionDialogTabType,
readonly: boolean = false,
): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, { const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tab }, data: {
collectionId: c?.id,
organizationId: this.organization?.id,
initialTab: tab,
readonly: readonly,
},
}); });
const result = await lastValueFrom(dialog.closed); const result = await lastValueFrom(dialog.closed);

View File

@@ -7501,6 +7501,9 @@
"collectionAccessRestricted": { "collectionAccessRestricted": {
"message": "Collection access is restricted" "message": "Collection access is restricted"
}, },
"readOnlyCollectionAccess": {
"message": "You do not have access to manage this collection."
},
"grantCollectionAccess": { "grantCollectionAccess": {
"message": "Grant groups or members access to this collection." "message": "Grant groups or members access to this collection."
}, },
@@ -7603,6 +7606,9 @@
"providerPortal": { "providerPortal": {
"message": "Provider Portal" "message": "Provider Portal"
}, },
"viewCollection": {
"message": "View collection"
},
"restrictedGroupAccess": { "restrictedGroupAccess": {
"message": "You cannot add yourself to groups." "message": "You cannot add yourself to groups."
}, },