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 { Directive } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
@@ -14,6 +12,7 @@ import {
ProviderUserStatusType, ProviderUserStatusType,
ProviderUserType, ProviderUserType,
} from "@bitwarden/common/admin-console/enums"; } 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 { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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>; 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. * 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 searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | null>(null); protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
constructor( constructor(
protected apiService: ApiService, protected apiService: ApiService,
@@ -100,15 +99,20 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
); );
} }
abstract edit(user: UserView): void; abstract edit(user: UserView, organization?: Organization): void;
abstract getUsers(): Promise<ListResponse<UserView> | UserView[]>; abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
abstract removeUser(id: string): Promise<void>; abstract removeUser(id: string, organization?: Organization): Promise<void>;
abstract reinviteUser(id: string): Promise<void>; abstract reinviteUser(id: string, organization?: Organization): Promise<void>;
abstract confirmUser(user: UserView, publicKey: Uint8Array): 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 // 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 // GetUsers can return a ListResponse or an Array
if (response instanceof ListResponse) { if (response instanceof ListResponse) {
@@ -120,10 +124,6 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.firstLoaded = true; this.firstLoaded = true;
} }
invite() {
this.edit(null);
}
protected async removeUserConfirmationDialog(user: UserView) { protected async removeUserConfirmationDialog(user: UserView) {
return this.dialogService.openSimpleDialog({ return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user), 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); const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) { if (!confirmed) {
return false; return false;
} }
this.actionPromise = this.removeUser(user.id); this.actionPromise = this.removeUser(user.id, organization);
try { try {
await this.actionPromise; await this.actionPromise;
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null,
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
}); });
this.dataSource.removeUser(user); this.dataSource.removeUser(user);
} catch (e) { } catch (e) {
this.validationService.showError(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) { if (this.actionPromise != null) {
return; return;
} }
this.actionPromise = this.reinviteUser(user.id); this.actionPromise = this.reinviteUser(user.id, organization);
try { try {
await this.actionPromise; await this.actionPromise;
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null,
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
}); });
} catch (e) { } catch (e) {
this.validationService.showError(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) => { const confirmUser = async (publicKey: Uint8Array) => {
try { try {
this.actionPromise = this.confirmUser(user, publicKey); this.actionPromise = this.confirmUser(user, publicKey, organization);
await this.actionPromise; await this.actionPromise;
user.status = this.userStatusType.Confirmed; user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user); this.dataSource.replaceUser(user);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null,
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
}); });
} catch (e) { } catch (e) {
this.validationService.showError(e); this.validationService.showError(e);
throw e; throw e;
} finally { } finally {
this.actionPromise = null; this.actionPromise = undefined;
} }
}; };
@@ -204,11 +201,14 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
const autoConfirm = await firstValueFrom( const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$, this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
); );
if (user == null) {
throw new Error("Cannot confirm null user.");
}
if (autoConfirm == null || !autoConfirm) { if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, { const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: { data: {
name: this.userNamePipe.transform(user), name: this.userNamePipe.transform(user),
userId: user != null ? user.userId : null, userId: user.id,
publicKey: publicKey, publicKey: publicKey,
confirmUser: () => confirmUser(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). * 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) { if (status == null) {
return user.status != OrganizationUserStatusType.Revoked; 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); return (user: UserViewTypes) => statusFilter(user, status) && textFilter(user, searchText);
} }

View File

@@ -1,466 +1,479 @@
<app-organization-free-trial-warning @let organization = this.organization();
[organization]="organization" @if (organization) {
(clicked)="navigateToPaymentMethod()" <app-organization-free-trial-warning
> [organization]="organization"
</app-organization-free-trial-warning> (clicked)="navigateToPaymentMethod(organization)"
<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"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> </app-organization-free-trial-warning>
{{ "inviteMember" | i18n }} <app-header>
</button> <bit-search
</app-header> class="tw-grow"
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
></bit-search>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4"> <button
<bit-toggle-group type="button"
[selected]="status" bitButton
(selectedChange)="statusToggle.next($event)" buttonType="primary"
[attr.aria-label]="'memberStatusFilter' | i18n" (click)="invite(organization)"
*ngIf="showUserManagementControls$ | async" [disabled]="!firstLoaded"
> *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 }} <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
</bit-callout> {{ "inviteMember" | i18n }}
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content </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. --> from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8"> <cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource"> <bit-table [dataSource]="dataSource">
<ng-container header> <ng-container header>
<tr> <tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls$ | async"> <th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input <input
type="checkbox" type="checkbox"
bitCheckbox bitCheckbox
class="tw-mr-1" class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)" (change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll" id="selectAll"
/> />
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{ <label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n "all" | i18n
}}</label> }}</label>
</th> </th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th> <th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th> <th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th> <th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th> <th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10"> <th bitCell class="tw-w-10">
<button <button
[bitMenuTriggerFor]="headerMenu" [bitMenuTriggerFor]="headerMenu"
type="button" type="button"
bitIconButton="bwi-ellipsis-v" bitIconButton="bwi-ellipsis-v"
size="small" size="small"
label="{{ 'options' | i18n }}" label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls$ | async" *ngIf="showUserManagementControls()"
></button> ></button>
<bit-menu #headerMenu> <bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager$ | async"> <ng-container *ngIf="canUseSecretsManager()">
<button type="button" bitMenuItem (click)="bulkEnableSM()"> <button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
{{ "activateSecretsManager" | i18n }} {{ "activateSecretsManager" | i18n }}
</button> </button>
<bit-menu-divider></bit-menu-divider> <bit-menu-divider></bit-menu-divider>
</ng-container> </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">
<button <button
type="button" type="button"
bitMenuItem bitMenuItem
(click)="reinvite(u)" (click)="bulkReinvite(organization)"
*ngIf="u.status === userStatusType.Invited" *ngIf="showBulkReinviteUsers"
> >
<i aria-hidden="true" class="bwi bwi-envelope"></i> <i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "resendInvitation" | i18n }} {{ "reinviteSelected" | i18n }}
</button> </button>
<button <button
type="button" type="button"
bitMenuItem bitMenuItem
(click)="confirm(u)" (click)="bulkConfirm(organization)"
*ngIf="u.status === userStatusType.Accepted" *ngIf="showBulkConfirmUsers"
> >
<span class="tw-text-success"> <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> </span>
</button> </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 <button
type="button" type="button"
bitMenuItem bitMenuItem
(click)="edit(u, memberTab.Groups)" (click)="bulkRestore(organization)"
*ngIf="organization.useGroups" *ngIf="showBulkRestoreUsers"
> >
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }} <i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
</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>
{{ "restoreAccess" | i18n }} {{ "restoreAccess" | i18n }}
</button> </button>
<button <button
type="button" type="button"
bitMenuItem bitMenuItem
(click)="revoke(u)" (click)="bulkRevoke(organization)"
*ngIf="u.status !== userStatusType.Revoked" *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 }} {{ "revokeAccess" | i18n }}
</button> </button>
<button <button
*ngIf="!u.managedByOrganization"
type="button" type="button"
bitMenuItem bitMenuItem
(click)="remove(u)" (click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
> >
<span class="tw-text-danger"> <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> </span>
</button> </button>
<button <button
*ngIf="u.managedByOrganization"
type="button" type="button"
bitMenuItem bitMenuItem
(click)="deleteUser(u)" (click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
> >
<span class="tw-text-danger"> <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 }} {{ "delete" | i18n }}
</span> </span>
</button> </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> </ng-container>
</bit-menu> <ng-container *ngIf="showEnrolledStatus($any(u), organization)">
</td> <i
</tr> class="bwi bwi-key"
</ng-template> title="{{ 'enrolledAccountRecovery' | i18n }}"
</bit-table> aria-hidden="true"
</cdk-virtual-scroll-viewport> ></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>
</ng-container> }

View File

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

View File

@@ -72,8 +72,8 @@ import { PaymentComponent } from "../shared/payment/payment.component";
type ChangePlanDialogParams = { type ChangePlanDialogParams = {
organizationId: string; organizationId: string;
subscription: OrganizationSubscriptionResponse;
productTierType: ProductTierType; productTierType: ProductTierType;
subscription?: OrganizationSubscriptionResponse;
}; };
// FIXME: update to use a const object instead of a typescript enum // 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> { async bulkRemove(): Promise<void> {
if (this.actionPromise != null) { if (this.actionPromise != null) {
return; return;

View File

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