1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-20050] Readonly members page for users with Account Recovery permission only (#14647)

* Add SeparateCustomRolePermissions feature flag

* Allow 'Manage Account Recovery' to be configured separately from 'Manage Users'

* Add showUserManagementControls$ observable to show/hide user management controls based on user permissions

* Update user management controls visibility to be dependant on user permissions.
This commit is contained in:
Rui Tomé
2025-05-19 13:17:12 +01:00
committed by GitHub
parent a82eb8c22b
commit ef592bf23a
2 changed files with 251 additions and 151 deletions

View File

@@ -5,7 +5,14 @@
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button type="button" bitButton buttonType="primary" (click)="invite()" [disabled]="!firstLoaded">
<button
type="button"
bitButton
buttonType="primary"
(click)="invite()"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls$ | async"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
@@ -16,6 +23,7 @@
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls$ | async"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
@@ -71,7 +79,7 @@
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls$ | async">
<input
type="checkbox"
bitCheckbox
@@ -94,6 +102,7 @@
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls$ | async"
></button>
<bit-menu #headerMenu>
@@ -174,74 +183,143 @@
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)">
<td
bitCell
(click)="dataSource.checkUser(u)"
*ngIf="showUserManagementControls$ | async"
>
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
<ng-container *ngIf="showUserManagementControls$ | async; else readOnlyUserInfo">
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
</div>
</div>
</div>
</div>
</td>
</td>
</ng-container>
<ng-template #readOnlyUserInfo>
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<span>{{ u.name ?? u.email }}</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
</div>
</div>
</div>
</td>
</ng-template>
<td
bitCell
(click)="edit(u, organization.useGroups ? memberTab.Groups : memberTab.Collections)"
class="tw-cursor-pointer"
>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
<ng-container *ngIf="showUserManagementControls$ | async; else readOnlyGroupsCell">
<td
bitCell
(click)="edit(u, organization.useGroups ? memberTab.Groups : memberTab.Collections)"
class="tw-cursor-pointer"
>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-container>
<ng-template #readOnlyGroupsCell>
<td bitCell>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-template>
<td
bitCell
(click)="edit(u, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
<ng-container *ngIf="showUserManagementControls$ | async; else readOnlyRoleCell">
<td
bitCell
(click)="edit(u, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
</ng-container>
<ng-template #readOnlyRoleCell>
<td bitCell class="tw-text-sm tw-text-muted">
{{ u.type | userType }}
</td>
</ng-template>
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
@@ -271,54 +349,58 @@
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Role)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Collections)">
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
<ng-container *ngIf="showUserManagementControls$ | async">
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Role)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Collections)">
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
</ng-container>
<!-- Account recovery is available to all users with appropriate permissions -->
<button
type="button"
bitMenuItem
@@ -327,45 +409,48 @@
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
<ng-container *ngIf="showUserManagementControls$ | async">
<button
type="button"
bitMenuItem
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
</bit-menu>
</td>
</tr>

View File

@@ -46,7 +46,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -102,6 +104,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
orgIsOnSecretsManagerStandalone = false;
protected canUseSecretsManager$: Observable<boolean>;
protected showUserManagementControls$: Observable<boolean>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 69;
@@ -135,6 +138,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
) {
super(
apiService,
@@ -229,6 +233,17 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
takeUntilDestroyed(),
)
.subscribe();
// Setup feature flag-dependent observables
const separateCustomRolePermissionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.SeparateCustomRolePermissions,
);
this.showUserManagementControls$ = separateCustomRolePermissionsEnabled$.pipe(
map(
(separateCustomRolePermissionsEnabled) =>
!separateCustomRolePermissionsEnabled || this.organization.canManageUsers,
),
);
}
async getUsers(): Promise<OrganizationUserView[]> {