1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-24412] Make billing api service call in members component non blocking (#16103)

* refactor organization to signal, unblock loading due to api call

* continue refactor WIP

* clean up

* refactor billingMetadata signal to observble

* deffer billing call

* refactor billingMetadata

* cleanup, add comment

* qa bug: add missing param
This commit is contained in:
Brandon Treston
2025-09-08 09:42:02 -04:00
committed by GitHub
parent 0b73b97d7a
commit b93602b09e
8 changed files with 714 additions and 675 deletions

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
@@ -14,6 +12,7 @@ import {
ProviderUserStatusType,
ProviderUserType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -66,20 +65,20 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
protected abstract dataSource: PeopleTableDataSource<UserView>;
firstLoaded: boolean;
firstLoaded: boolean = false;
/**
* The currently selected status filter, or null to show all active users.
* The currently selected status filter, or undefined to show all active users.
*/
status: StatusType | null;
status?: StatusType;
/**
* The currently executing promise - used to avoid multiple user actions executing at once.
*/
actionPromise: Promise<void>;
actionPromise?: Promise<void>;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | null>(null);
protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
constructor(
protected apiService: ApiService,
@@ -100,15 +99,20 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
);
}
abstract edit(user: UserView): void;
abstract getUsers(): Promise<ListResponse<UserView> | UserView[]>;
abstract removeUser(id: string): Promise<void>;
abstract reinviteUser(id: string): Promise<void>;
abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise<void>;
abstract edit(user: UserView, organization?: Organization): void;
abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
abstract removeUser(id: string, organization?: Organization): Promise<void>;
abstract reinviteUser(id: string, organization?: Organization): Promise<void>;
abstract confirmUser(
user: UserView,
publicKey: Uint8Array,
organization?: Organization,
): Promise<void>;
abstract invite(organization?: Organization): void;
async load() {
async load(organization?: Organization) {
// Load new users from the server
const response = await this.getUsers();
const response = await this.getUsers(organization);
// GetUsers can return a ListResponse or an Array
if (response instanceof ListResponse) {
@@ -120,10 +124,6 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.firstLoaded = true;
}
invite() {
this.edit(null);
}
protected async removeUserConfirmationDialog(user: UserView) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
@@ -132,64 +132,61 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
});
}
async remove(user: UserView) {
async remove(user: UserView, organization?: Organization) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.removeUser(user.id);
this.actionPromise = this.removeUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
async reinvite(user: UserView) {
async reinvite(user: UserView, organization?: Organization) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.reinviteUser(user.id);
this.actionPromise = this.reinviteUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
async confirm(user: UserView) {
async confirm(user: UserView, organization?: Organization) {
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey);
this.actionPromise = this.confirmUser(user, publicKey, organization);
await this.actionPromise;
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} catch (e) {
this.validationService.showError(e);
throw e;
} finally {
this.actionPromise = null;
this.actionPromise = undefined;
}
};
@@ -204,11 +201,14 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (user == null) {
throw new Error("Cannot confirm null user.");
}
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user != null ? user.userId : null,
userId: user.id,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},

View File

@@ -13,7 +13,7 @@ const MaxCheckedCount = 500;
/**
* Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked).
*/
function statusFilter(user: UserViewTypes, status: StatusType) {
function statusFilter(user: UserViewTypes, status?: StatusType) {
if (status == null) {
return user.status != OrganizationUserStatusType.Revoked;
}
@@ -35,7 +35,7 @@ function textFilter(user: UserViewTypes, text: string) {
);
}
export function peopleFilter(searchText: string, status: StatusType) {
export function peopleFilter(searchText: string, status?: StatusType) {
return (user: UserViewTypes) => statusFilter(user, status) && textFilter(user, searchText);
}

View File

@@ -1,466 +1,479 @@
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
(click)="invite()"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls$ | async"
@let organization = this.organization();
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod(organization)"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</app-header>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
></bit-search>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls$ | async"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls()"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls()"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls$ | async">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls$ | async"
></button>
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager$ | async">
<button type="button" bitMenuItem (click)="bulkEnableSM()">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="bulkReinvite()"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkRestore()"
*ngIf="showBulkRestoreUsers"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRevoke()"
*ngIf="showBulkRevokeUsers"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRemove()"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkDelete()"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr
bitRow
*cdkVirtualFor="let u of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td
bitCell
(click)="dataSource.checkUser(u)"
*ngIf="showUserManagementControls$ | async"
>
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<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>
</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>
<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>
<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 tw-space-x-2">
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u))">
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls$ | async">
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
(click)="bulkReinvite(organization)"
*ngIf="showBulkReinviteUsers"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
(click)="bulkConfirm(organization)"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | 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"
(click)="bulkRestore(organization)"
*ngIf="showBulkRestoreUsers"
>
<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
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</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>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
(click)="bulkRevoke(organization)"
*ngIf="showBulkRevokeUsers"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u)"
(click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u)"
(click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr
bitRow
*cdkVirtualFor="let u of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
<td bitCell (click)="edit(u, organization)" 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>
</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>
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
<td
bitCell
(click)="
edit(
u,
organization,
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>
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
<td
bitCell
(click)="edit(u, organization, 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">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
<ng-container *ngIf="showEnrolledStatus($any(u), organization)">
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="reinvite(u, organization)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u, organization)"
*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, organization, memberTab.Role)"
>
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, 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, organization)"
*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
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="restore(u, organization)"
*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, organization)"
*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, organization)"
>
<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, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
</ng-container>
}

View File

@@ -1,11 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, computed, Signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
filter,
firstValueFrom,
from,
lastValueFrom,
@@ -14,6 +14,7 @@ import {
Observable,
shareReplay,
switchMap,
take,
} from "rxjs";
import {
@@ -47,13 +48,14 @@ 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 { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
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";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -102,23 +104,24 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
organization: Organization;
status: OrganizationUserStatusType = null;
organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
orgResetPasswordPolicyEnabled = false;
orgIsOnSecretsManagerStandalone = false;
protected canUseSecretsManager$: Observable<boolean>;
protected showUserManagementControls$: Observable<boolean>;
protected canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
);
protected showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
private refreshBillingMetadata$: BehaviorSubject<null> = new BehaviorSubject(null);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
private organizationUsersCount = 0;
get occupiedSeatCount(): number {
return this.organizationUsersCount;
}
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
constructor(
apiService: ApiService,
@@ -162,61 +165,58 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const organization$ = this.route.params.pipe(
concatMap((params) =>
this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.userId$.pipe(
switchMap((userId) =>
this.organizationService
.organizations$(account?.id)
.organizations$(userId)
.pipe(getOrganizationById(params.organizationId)),
),
),
),
filter((organization): organization is Organization => organization != null),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager));
this.organization = toSignal(organization$);
const policies$ = combineLatest([
this.accountService.activeAccount$.pipe(getUserId),
organization$,
]).pipe(
switchMap(([userId, organization]) => {
if (organization.isProviderUser) {
return from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
);
}
return this.policyService.policies$(userId);
}),
const policies$ = combineLatest([this.userId$, organization$]).pipe(
switchMap(([userId, organization]) =>
organization.isProviderUser
? from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
)
: this.policyService.policies$(userId),
),
);
combineLatest([this.route.queryParams, policies$, organization$])
.pipe(
concatMap(async ([qParams, policies, organization]) => {
this.organization = organization;
// Backfill pub/priv key if necessary
if (
this.organization.canManageUsersPassword &&
!this.organization.hasPublicAndPrivateKeys
) {
if (organization.canManageUsersPassword && !organization.hasPublicAndPrivateKeys) {
const orgShareKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
const response = await this.organizationApiService.updateKeys(
this.organization.id,
request,
const [orgPublicKey, encryptedOrgPrivateKey] =
await this.keyService.makeKeyPair(orgShareKey);
if (encryptedOrgPrivateKey.encryptedString == null) {
throw new Error("Encrypted private key is null.");
}
const request = new OrganizationKeysRequest(
orgPublicKey,
encryptedOrgPrivateKey.encryptedString,
);
const response = await this.organizationApiService.updateKeys(organization.id, request);
if (response != null) {
this.organization.hasPublicAndPrivateKeys =
response.publicKey != null && response.privateKey != null;
await this.syncService.fullSync(true); // Replace organizations with new data
} else {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
@@ -225,24 +225,17 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const resetPasswordPolicy = policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === this.organization.id);
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled;
.find((p) => p.organizationId === organization.id);
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled ?? false;
const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata(
this.organization.id,
);
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
this.organizationUsersCount = billingMetadata.organizationOccupiedSeats;
await this.load();
await this.load(organization);
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.openEventsDialog(user[0]);
this.openEventsDialog(user[0], organization);
}
}
}),
@@ -250,10 +243,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
)
.subscribe();
this.showUserManagementControls$ = organization$.pipe(
map((organization) => organization.canManageUsers),
);
organization$
.pipe(
switchMap((organization) =>
@@ -265,23 +254,40 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
takeUntilDestroyed(),
)
.subscribe();
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
switchMap(([_, organization]) =>
this.billingApiService.getOrganizationBillingMetadata(organization.id),
),
takeUntilDestroyed(),
shareReplay({ bufferSize: 1, refCount: false }),
);
// Stripe is slow, so kick this off in the background but without blocking page load.
// Anyone who needs it will still await the first emission.
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
}
async getUsers(): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>>;
let collectionsPromise: Promise<Map<string, string>>;
override async load(organization: Organization) {
this.refreshBillingMetadata$.next(null);
await super.load(organization);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>> | undefined;
let collectionsPromise: Promise<Map<string, string>> | undefined;
// We don't need both groups and collections for the table, so only load one
const userPromise = this.organizationUserApiService.getAllUsers(this.organization.id, {
includeGroups: this.organization.useGroups,
includeCollections: !this.organization.useGroups,
const userPromise = this.organizationUserApiService.getAllUsers(organization.id, {
includeGroups: organization.useGroups,
includeCollections: !organization.useGroups,
});
// Depending on which column is displayed, we need to load the group/collection names
if (this.organization.useGroups) {
groupsPromise = this.getGroupNameMap();
if (organization.useGroups) {
groupsPromise = this.getGroupNameMap(organization);
} else {
collectionsPromise = this.getCollectionNameMap();
collectionsPromise = this.getCollectionNameMap(organization);
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
@@ -290,22 +296,26 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
collectionsPromise,
]);
return usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
return (
usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g) => groupNamesMap.get(g))
.sort(this.i18nService.collator?.compare);
userView.collectionNames = userView.collections
.map((c) => collectionNamesMap.get(c.id))
.sort(this.i18nService.collator?.compare);
userView.groupNames = userView.groups
.map((g) => groupNamesMap?.get(g))
.filter((name): name is string => name != null)
.sort(this.i18nService.collator?.compare);
userView.collectionNames = userView.collections
.map((c) => collectionNamesMap?.get(c.id))
.filter((name): name is string => name != null)
.sort(this.i18nService.collator?.compare);
return userView;
});
return userView;
}) ?? []
);
}
async getGroupNameMap(): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(this.organization.id);
async getGroupNameMap(organization: Organization): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(organization.id);
const groupNameMap = new Map<string, string>();
groups.forEach((g) => groupNameMap.set(g.id, g.name));
return groupNameMap;
@@ -314,8 +324,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
/**
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const response = from(this.apiService.getCollections(this.organization.id)).pipe(
async getCollectionNameMap(organization: Organization) {
const response = from(this.apiService.getCollections(organization.id)).pipe(
map((res) =>
res.data.map((r) =>
Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)),
@@ -324,9 +334,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
);
const decryptedCollections$ = combineLatest([
this.accountService.activeAccount$.pipe(
getUserId,
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
filter((orgKeys) => orgKeys != null),
),
response,
]).pipe(
@@ -343,91 +353,94 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return await firstValueFrom(decryptedCollections$);
}
removeUser(id: string): Promise<void> {
return this.organizationUserApiService.removeOrganizationUser(this.organization.id, id);
removeUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.removeOrganizationUser(organization.id, id);
}
revokeUser(id: string): Promise<void> {
return this.organizationUserApiService.revokeOrganizationUser(this.organization.id, id);
revokeUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.revokeOrganizationUser(organization.id, id);
}
restoreUser(id: string): Promise<void> {
return this.organizationUserApiService.restoreOrganizationUser(this.organization.id, id);
restoreUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.restoreOrganizationUser(organization.id, id);
}
reinviteUser(id: string): Promise<void> {
return this.organizationUserApiService.postOrganizationUserReinvite(this.organization.id, id);
reinviteUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.postOrganizationUserReinvite(organization.id, id);
}
async confirmUser(user: OrganizationUserView, publicKey: Uint8Array): Promise<void> {
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<void> {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(this.organization, user, publicKey),
);
await firstValueFrom(this.organizationUserService.confirmUser(organization, user, publicKey));
} else {
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
filter((orgKeys) => orgKeys != null),
map((orgKeys) => orgKeys[organization.id]),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
await this.organizationUserApiService.postOrganizationUserConfirm(
this.organization.id,
organization.id,
user.id,
request,
);
}
}
async revoke(user: OrganizationUserView) {
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id);
this.actionPromise = this.revokeUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load();
await this.load(organization);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
async restore(user: OrganizationUserView) {
this.actionPromise = this.restoreUser(user.id);
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load();
await this.load(organization);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
allowResetPassword(orgUser: OrganizationUserView): boolean {
// Hierarchy check
allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean {
let callingUserHasPermission = false;
switch (this.organization.type) {
switch (organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
@@ -441,33 +454,35 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
break;
}
// Final
return (
this.organization.canManageUsersPassword &&
organization.canManageUsersPassword &&
callingUserHasPermission &&
this.organization.useResetPassword &&
this.organization.hasPublicAndPrivateKeys &&
organization.useResetPassword &&
organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
showEnrolledStatus(orgUser: OrganizationUserUserDetailsResponse): boolean {
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
organization: Organization,
): boolean {
return (
this.organization.useResetPassword &&
organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled
);
}
private getManageBillingText(): string {
return this.organization.canEditSubscription ? "ManageBilling" : "NoManageBilling";
private getManageBillingText(organization: Organization): string {
return organization.canEditSubscription ? "ManageBilling" : "NoManageBilling";
}
private getProductKey(productType: ProductTierType): string {
private getProductKey(organization: Organization): string {
let product = "";
switch (productType) {
switch (organization.productTierType) {
case ProductTierType.Free:
product = "freeOrg";
break;
@@ -478,24 +493,21 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
product = "familiesPlan";
break;
default:
throw new Error(`Unsupported product type: ${productType}`);
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
return `${product}InvLimitReached${this.getManageBillingText()}`;
return `${product}InvLimitReached${this.getManageBillingText(organization)}`;
}
private getDialogContent(): string {
return this.i18nService.t(
this.getProductKey(this.organization.productTierType),
this.organization.seats,
);
private getDialogContent(organization: Organization): string {
return this.i18nService.t(this.getProductKey(organization), organization.seats);
}
private getAcceptButtonText(): string {
if (!this.organization.canEditSubscription) {
private getAcceptButtonText(organization: Organization): string {
if (!organization.canEditSubscription) {
return this.i18nService.t("ok");
}
const productType = this.organization.productTierType;
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${productType}`);
@@ -504,82 +516,88 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return this.i18nService.t("upgrade");
}
private async handleDialogClose(result: boolean | undefined): Promise<void> {
if (!result || !this.organization.canEditSubscription) {
private async handleDialogClose(
result: boolean | undefined,
organization: Organization,
): Promise<void> {
if (!result || !organization.canEditSubscription) {
return;
}
const productType = this.organization.productTierType;
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${this.organization.productTierType}`);
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
await this.router.navigate(
["/organizations", this.organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } },
);
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
private async showSeatLimitReachedDialog(): Promise<void> {
private async showSeatLimitReachedDialog(organization: Organization): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.getDialogContent(),
content: this.getDialogContent(organization),
type: "primary",
acceptButtonText: this.getAcceptButtonText(),
acceptButtonText: this.getAcceptButtonText(organization),
};
if (!this.organization.canEditSubscription) {
if (!organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.cancelButtonText = null;
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this));
await lastValueFrom(
simpleDialog.closed.pipe(map((closed) => this.handleDialogClose(closed, organization))),
);
}
private async handleInviteDialog() {
private async handleInviteDialog(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Add",
organizationId: this.organization.id,
organizationId: organization.id,
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
occupiedSeatCount: this.occupiedSeatCount,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === MemberDialogResult.Saved) {
await this.load();
await this.load(organization);
}
}
private async handleSeatLimitForFixedTiers() {
if (!this.organization.canEditSubscription) {
await this.showSeatLimitReachedDialog();
private async handleSeatLimitForFixedTiers(organization: Organization) {
if (!organization.canEditSubscription) {
await this.showSeatLimitReachedDialog(organization);
return;
}
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: this.organization.id,
subscription: null,
productTierType: this.organization.productTierType,
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Submitted) {
await this.load();
await this.load(organization);
}
}
async invite() {
if (this.organization.hasReseller && this.organization.seats === this.occupiedSeatCount) {
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
if (
organization.hasReseller &&
organization.seats === billingMetadata?.organizationOccupiedSeats
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
@@ -590,26 +608,31 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
if (
this.occupiedSeatCount === this.organization.seats &&
isFixedSeatPlan(this.organization.productTierType)
billingMetadata?.organizationOccupiedSeats === organization.seats &&
isFixedSeatPlan(organization.productTierType)
) {
await this.handleSeatLimitForFixedTiers();
await this.handleSeatLimitForFixedTiers(organization);
return;
}
await this.handleInviteDialog();
await this.handleInviteDialog(organization);
}
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
async edit(
user: OrganizationUserView,
organization: Organization,
initialTab: MemberDialogTab = MemberDialogTab.Role,
) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationId: organization.id,
organizationUserId: user.id,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
initialTab: initialTab,
managedByOrganization: user.managedByOrganization,
},
@@ -623,35 +646,35 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
await this.load();
await this.load(organization);
break;
}
}
async bulkRemove() {
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.load();
await this.load(organization);
}
async bulkDelete() {
async bulkDelete(organization: Organization) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id),
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
this.organization.canManageUsers &&
this.organization.productTierType === ProductTierType.Enterprise
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
@@ -665,38 +688,38 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.load();
await this.load(organization);
}
async bulkRevoke() {
await this.bulkRevokeOrRestore(true);
async bulkRevoke(organization: Organization) {
await this.bulkRevokeOrRestore(true, organization);
}
async bulkRestore() {
await this.bulkRevokeOrRestore(false);
async bulkRestore(organization: Organization) {
await this.bulkRevokeOrRestore(false, organization);
}
async bulkRevokeOrRestore(isRevoking: boolean) {
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
if (this.actionPromise != null) {
return;
}
const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
organizationId: this.organization.id,
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
isRevoking: isRevoking,
});
await firstValueFrom(ref.closed);
await this.load();
await this.load(organization);
}
async bulkReinvite() {
async bulkReinvite(organization: Organization) {
if (this.actionPromise != null) {
return;
}
@@ -715,7 +738,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
try {
const response = this.organizationUserApiService.postManyOrganizationUserReinvite(
this.organization.id,
organization.id,
filteredUsers.map((user) => user.id),
);
// Bulk Status component open
@@ -731,26 +754,26 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
async bulkConfirm() {
async bulkConfirm(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organization: this.organization,
organization: organization,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.load();
await this.load(organization);
}
async bulkEnableSM() {
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager);
if (users.length === 0) {
@@ -763,20 +786,20 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, {
orgId: this.organization.id,
orgId: organization.id,
users,
});
await lastValueFrom(dialogRef.closed);
this.dataSource.uncheckAllUsers();
await this.load();
await this.load(organization);
}
openEventsDialog(user: OrganizationUserView) {
openEventsDialog(user: OrganizationUserView, organization: Organization) {
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationId: organization.id,
entityId: user.id,
showUser: false,
entity: "user",
@@ -784,7 +807,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
});
}
async resetPassword(user: OrganizationUserView) {
async resetPassword(user: OrganizationUserView, organization: Organization) {
if (!user || !user.email || !user.id) {
this.toastService.showToast({
variant: "error",
@@ -800,14 +823,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
data: {
name: this.userNamePipe.transform(user),
email: user.email,
organizationId: this.organization.id as OrganizationId,
organizationId: organization.id as OrganizationId,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load();
await this.load(organization);
}
return;
@@ -857,15 +880,15 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return true;
}
async deleteUser(user: OrganizationUserView) {
async deleteUser(user: OrganizationUserView, organization: Organization) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id),
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
this.organization.canManageUsers &&
this.organization.productTierType === ProductTierType.Enterprise
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
@@ -891,24 +914,23 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return false;
}
await this.deleteManagedMemberWarningService.acknowledgeWarning(this.organization.id);
await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id);
this.actionPromise = this.organizationUserApiService.deleteOrganizationUser(
this.organization.id,
organization.id,
user.id,
);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
this.actionPromise = undefined;
}
private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) {
@@ -952,12 +974,12 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
async navigateToPaymentMethod() {
async navigateToPaymentMethod(organization: Organization) {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
await this.router.navigate(["organizations", `${organization.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}

View File

@@ -72,8 +72,8 @@ import { PaymentComponent } from "../shared/payment/payment.component";
type ChangePlanDialogParams = {
organizationId: string;
subscription: OrganizationSubscriptionResponse;
productTierType: ProductTierType;
subscription?: OrganizationSubscriptionResponse;
};
// FIXME: update to use a const object instead of a typescript enum

View File

@@ -170,6 +170,10 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
}
}
async invite() {
await this.edit(null);
}
async bulkRemove(): Promise<void> {
if (this.actionPromise != null) {
return;

View File

@@ -36,7 +36,7 @@ export class Policy extends Domain {
return new Policy(new PolicyData(response));
}
static fromListResponse(response: ListResponse<PolicyResponse>): Policy[] | undefined {
return response.data?.map((d) => Policy.fromResponse(d)) ?? undefined;
static fromListResponse(response: ListResponse<PolicyResponse>): Policy[] {
return response.data.map((d) => Policy.fromResponse(d));
}
}

View File

@@ -13,7 +13,7 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
import { POLICIES } from "./policy-state";
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }): Policy[] {
return Object.values(policiesMap || {}).map((f) => new Policy(f));
}