1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 17:53:41 +00:00

Merge branch 'main' into SM-1570

This commit is contained in:
cd-bitwarden
2025-10-23 11:40:11 -04:00
committed by GitHub
88 changed files with 3260 additions and 677 deletions

View File

@@ -24,6 +24,7 @@ import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service";
import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source";
@@ -75,7 +76,7 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
/**
* The currently executing promise - used to avoid multiple user actions executing at once.
*/
actionPromise?: Promise<void>;
actionPromise?: Promise<MemberActionResult>;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
@@ -101,13 +102,13 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
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 removeUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract reinviteUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract confirmUser(
user: UserView,
publicKey: Uint8Array,
organization?: Organization,
): Promise<void>;
): Promise<MemberActionResult>;
abstract invite(organization?: Organization): void;
async load(organization?: Organization) {
@@ -140,12 +141,16 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.actionPromise = this.removeUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -159,11 +164,15 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.actionPromise = this.reinviteUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -174,14 +183,18 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey, organization);
await this.actionPromise;
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
const result = await this.actionPromise;
if (result.success) {
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
throw e;

View File

@@ -50,6 +50,8 @@ export enum BulkCollectionsDialogResult {
Canceled = "canceled",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [SharedModule, AccessSelectorModule],
selector: "app-bulk-collections-dialog",

View File

@@ -6,6 +6,8 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "collection-access-restricted",
imports: [SharedModule, ButtonModule, NoItemsModule],
@@ -37,9 +39,15 @@ export class CollectionAccessRestrictedComponent {
protected icon = RestrictedView;
protected collectionDialogTabType = CollectionDialogTabType;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() canEditCollection = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() canViewCollectionInfo = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() viewCollectionClicked = new EventEmitter<{
readonly: boolean;
tab: CollectionDialogTabType;

View File

@@ -9,13 +9,19 @@ import { CollectionId } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared/shared.module";
import { GetCollectionNameFromIdPipe } from "../pipes";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-collection-badge",
templateUrl: "collection-name-badge.component.html",
imports: [SharedModule, GetCollectionNameFromIdPipe],
})
export class CollectionNameBadgeComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() collectionIds: CollectionId[] | string[];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() collections: CollectionView[];
get shownCollections(): string[] {

View File

@@ -7,13 +7,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { GroupView } from "../../core";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-group-badge",
templateUrl: "group-name-badge.component.html",
standalone: false,
})
export class GroupNameBadgeComponent implements OnChanges {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selectedGroups: SelectionReadOnlyRequest[];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() allGroups: GroupView[];
protected groupNames: string[] = [];

View File

@@ -24,6 +24,8 @@ import {
} from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter-section.type";
import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-organization-vault-filter",
templateUrl:
@@ -34,6 +36,8 @@ export class VaultFilterComponent
extends BaseVaultFilterComponent
implements OnInit, OnDestroy, OnChanges
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() set organization(value: Organization) {
if (value && value !== this._organization) {
this._organization = value;

View File

@@ -37,6 +37,8 @@ import {
} from "../../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { CollectionDialogTabType } from "../../shared/components/collection-dialog";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-vault-header",
templateUrl: "./vault-header.component.html",
@@ -59,36 +61,56 @@ export class VaultHeaderComponent {
* Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading: boolean;
/** Current active filter */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() filter: RoutedVaultFilterModel;
/** The organization currently being viewed */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() organization: Organization;
/** Currently selected collection */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() collection?: TreeNode<CollectionAdminView>;
/** The current search text in the header */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() searchText: string;
/** Emits an event when the new item button is clicked in the header */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
/** Emits an event when the new collection button is clicked in the header */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAddCollection = new EventEmitter<void>();
/** Emits an event when the edit collection button is clicked in the header */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onEditCollection = new EventEmitter<{
tab: CollectionDialogTabType;
readonly: boolean;
}>();
/** Emits an event when the delete collection button is clicked in the header */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDeleteCollection = new EventEmitter<void>();
/** Emits an event when the search text changes in the header*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() searchTextChanged = new EventEmitter<string>();
protected CollectionDialogTabType = CollectionDialogTabType;

View File

@@ -140,6 +140,8 @@ enum AddAccessStatusType {
AddAccess = 1,
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-vault",
templateUrl: "vault.component.html",
@@ -207,6 +209,8 @@ export class VaultComponent implements OnInit, OnDestroy {
protected selectedCollection$: Observable<TreeNode<CollectionAdminView> | undefined>;
private nestedCollections$: Observable<TreeNode<CollectionAdminView>[]>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("vaultItems", { static: false }) vaultItemsComponent:
| VaultItemsComponent<CipherView>
| undefined;

View File

@@ -6,17 +6,31 @@ import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-info",
templateUrl: "organization-information.component.html",
standalone: false,
})
export class OrganizationInformationComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() nameOnly = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() createOrganization = true;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() isProvider = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() acceptingSponsorship = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() formGroup: UntypedFormGroup;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() changedBusinessOwned = new EventEmitter<void>();
constructor(private accountService: AccountService) {}

View File

@@ -19,18 +19,24 @@ import { DialogService } from "@bitwarden/components";
import { isEnterpriseOrgGuard } from "./is-enterprise-org.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the home screen!</h1>",
standalone: false,
})
export class HomescreenComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This component can only be accessed by a enterprise organization!</h1>",
standalone: false,
})
export class IsEnterpriseOrganizationComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the organization upgrade screen!</h1>",
standalone: false,

View File

@@ -18,18 +18,24 @@ import { DialogService } from "@bitwarden/components";
import { isPaidOrgGuard } from "./is-paid-org.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the home screen!</h1>",
standalone: false,
})
export class HomescreenComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This component can only be accessed by a paid organization!</h1>",
standalone: false,
})
export class PaidOrganizationOnlyComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the organization upgrade screen!</h1>",
standalone: false,

View File

@@ -17,18 +17,24 @@ import { UserId } from "@bitwarden/common/types/guid";
import { organizationRedirectGuard } from "./org-redirect.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the home screen!</h1>",
standalone: false,
})
export class HomescreenComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1>This is the admin console!</h1>",
standalone: false,
})
export class AdminConsoleComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "<h1> This is a subroute of the admin console!</h1>",
standalone: false,

View File

@@ -36,6 +36,8 @@ import { FreeFamiliesPolicyService } from "../../../billing/services/free-famili
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { WebLayoutModule } from "../../../layouts/web-layout.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-organization-layout",
templateUrl: "organization-layout.component.html",

View File

@@ -37,6 +37,8 @@ export interface EntityEventsDialogParams {
name?: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [SharedModule],
templateUrl: "entity-events.component.html",

View File

@@ -46,6 +46,8 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record<EventSystemUser, string> = {
[EventSystemUser.PublicApi]: "publicApi",
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "events.component.html",
imports: [SharedModule, HeaderModule],

View File

@@ -107,6 +107,8 @@ export const openGroupAddEditDialog = (
);
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-group-add-edit",
templateUrl: "group-add-edit.component.html",

View File

@@ -77,6 +77,8 @@ const groupsFilter = (filter: string) => {
};
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "groups.component.html",
standalone: false,

View File

@@ -17,6 +17,8 @@ export type UserConfirmDialogData = {
confirmUser: (publicKey: Uint8Array) => Promise<void>;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "user-confirm.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ import { ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "verify-recover-delete-org.component.html",
imports: [SharedModule],

View File

@@ -61,6 +61,8 @@ export type AccountRecoveryDialogResultType =
* given organization user. An admin will access this form when they want to
* reset a user's password and log them out of sessions.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
standalone: true,
selector: "app-account-recovery-dialog",
@@ -76,6 +78,8 @@ export type AccountRecoveryDialogResultType =
],
})
export class AccountRecoveryDialogComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;

View File

@@ -36,6 +36,8 @@ type BulkConfirmDialogParams = {
users: BulkUserDetails[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-confirm-dialog.component.html",
standalone: false,

View File

@@ -16,6 +16,8 @@ type BulkDeleteDialogParams = {
users: BulkUserDetails[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-delete-dialog.component.html",
standalone: false,

View File

@@ -20,6 +20,8 @@ export type BulkEnableSecretsManagerDialogData = {
users: OrganizationUserView[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: `bulk-enable-sm-dialog.component.html`,
standalone: false,

View File

@@ -19,6 +19,8 @@ type BulkRemoveDialogParams = {
users: BulkUserDetails[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-remove-dialog.component.html",
standalone: false,

View File

@@ -15,6 +15,8 @@ type BulkRestoreDialogParams = {
isRevoking: boolean;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-bulk-restore-revoke",
templateUrl: "bulk-restore-revoke.component.html",

View File

@@ -38,6 +38,8 @@ type BulkStatusDialogData = {
successfulMessage: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-bulk-status",
templateUrl: "bulk-status.component.html",

View File

@@ -104,6 +104,8 @@ export enum MemberDialogResult {
Restored = "restored",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "member-dialog.component.html",
standalone: false,

View File

@@ -7,6 +7,8 @@ import { Subject, takeUntil } from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-nested-checkbox",
templateUrl: "nested-checkbox.component.html",
@@ -15,7 +17,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
export class NestedCheckboxComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() parentId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() checkboxes: FormGroup<Record<string, FormControl<boolean>>>;
get parentIndeterminate() {

View File

@@ -2,7 +2,7 @@
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod(organization)"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
>
</app-organization-free-trial-warning>
<app-header>
@@ -339,7 +339,10 @@
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u), organization)">
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
@@ -422,7 +425,7 @@
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>

View File

@@ -4,6 +4,7 @@ import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -18,6 +19,11 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
} from "./services";
@NgModule({
imports: [
@@ -40,5 +46,11 @@ import { MembersComponent } from "./members.component";
MembersComponent,
BulkDeleteDialogComponent,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
],
})
export class MembersModule {}

View File

@@ -0,0 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";
export { OrganizationUserService } from "./organization-user/organization-user.service";

View File

@@ -0,0 +1,463 @@
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import {
OrganizationUserType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
import { MemberActionsService } from "./member-actions.service";
describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let billingConstraintService: MockProxy<BillingConstraintService>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
let mockOrganization: Organization;
let mockOrgUser: OrganizationUserView;
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
billingConstraintService = mock<BillingConstraintService>();
mockOrganization = {
id: organizationId,
type: OrganizationUserType.Owner,
canManageUsersPassword: true,
hasPublicAndPrivateKeys: true,
useResetPassword: true,
} as Organization;
mockOrgUser = {
id: userIdToManage,
userId: userIdToManage,
type: OrganizationUserType.User,
status: OrganizationUserStatusType.Confirmed,
resetPasswordEnrolled: true,
} as OrganizationUserView;
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
keyService,
encryptService,
configService,
accountService,
billingConstraintService,
);
});
describe("inviteUser", () => {
it("should successfully invite a user", async () => {
organizationUserApiService.postOrganizationUserInvite.mockResolvedValue(undefined);
const result = await service.inviteUser(
mockOrganization,
"test@example.com",
OrganizationUserType.User,
{},
[],
[],
);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.postOrganizationUserInvite).toHaveBeenCalledWith(
organizationId,
{
emails: ["test@example.com"],
type: OrganizationUserType.User,
accessSecretsManager: false,
collections: [],
groups: [],
permissions: {},
},
);
});
it("should handle invite errors", async () => {
const errorMessage = "Invitation failed";
organizationUserApiService.postOrganizationUserInvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.inviteUser(
mockOrganization,
"test@example.com",
OrganizationUserType.User,
);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("removeUser", () => {
it("should successfully remove a user", async () => {
organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined);
const result = await service.removeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.removeOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle remove errors", async () => {
const errorMessage = "Remove failed";
organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.removeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("revokeUser", () => {
it("should successfully revoke a user", async () => {
organizationUserApiService.revokeOrganizationUser.mockResolvedValue(undefined);
const result = await service.revokeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.revokeOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle revoke errors", async () => {
const errorMessage = "Revoke failed";
organizationUserApiService.revokeOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.revokeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("restoreUser", () => {
it("should successfully restore a user", async () => {
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
const result = await service.restoreUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle restore errors", async () => {
const errorMessage = "Restore failed";
organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.restoreUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("deleteUser", () => {
it("should successfully delete a user", async () => {
organizationUserApiService.deleteOrganizationUser.mockResolvedValue(undefined);
const result = await service.deleteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.deleteOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle delete errors", async () => {
const errorMessage = "Delete failed";
organizationUserApiService.deleteOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.deleteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("reinviteUser", () => {
it("should successfully reinvite a user", async () => {
organizationUserApiService.postOrganizationUserReinvite.mockResolvedValue(undefined);
const result = await service.reinviteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.postOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle reinvite errors", async () => {
const errorMessage = "Reinvite failed";
organizationUserApiService.postOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.reinviteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("confirmUser", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should confirm user using new flow when feature flag is enabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationUserService.confirmUser.mockReturnValue(of(undefined));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(organizationUserService.confirmUser).toHaveBeenCalledWith(
mockOrganization,
mockOrgUser,
publicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should confirm user using exising flow when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const mockOrgKey = mock<OrgKey>();
const mockOrgKeys = { [organizationId]: mockOrgKey };
keyService.orgKeys$.mockReturnValue(of(mockOrgKeys));
const mockEncryptedKey = new EncString("encrypted-key-data");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(keyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
organizationId,
userIdToManage,
expect.objectContaining({
key: "encrypted-key-data",
}),
);
});
it("should handle missing organization keys", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
keyService.orgKeys$.mockReturnValue(of({}));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain("Organization keys not found");
});
it("should handle confirm errors", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const errorMessage = "Confirm failed";
organizationUserService.confirmUser.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain(errorMessage);
});
});
describe("bulkReinvite", () => {
const userIds = [newGuid(), newGuid(), newGuid()];
it("should successfully reinvite multiple users", async () => {
const mockResponse = {
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
} as ListResponse<OrganizationUserBulkResponse>;
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result).toEqual({
successful: mockResponse,
failed: [],
});
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
});
});
describe("allowResetPassword", () => {
const resetPasswordEnabled = true;
it("should allow reset password for Owner over User", () => {
const result = service.allowResetPassword(
mockOrgUser,
mockOrganization,
resetPasswordEnabled,
);
expect(result).toBe(true);
});
it("should allow reset password for Admin over User", () => {
const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization;
const result = service.allowResetPassword(mockOrgUser, adminOrg, resetPasswordEnabled);
expect(result).toBe(true);
});
it("should not allow reset password for Admin over Owner", () => {
const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization;
const ownerUser = {
...mockOrgUser,
type: OrganizationUserType.Owner,
} as OrganizationUserView;
const result = service.allowResetPassword(ownerUser, adminOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should allow reset password for Custom over User", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const result = service.allowResetPassword(mockOrgUser, customOrg, resetPasswordEnabled);
expect(result).toBe(true);
});
it("should not allow reset password for Custom over Admin", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const adminUser = {
...mockOrgUser,
type: OrganizationUserType.Admin,
} as OrganizationUserView;
const result = service.allowResetPassword(adminUser, customOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password for Custom over Owner", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const ownerUser = {
...mockOrgUser,
type: OrganizationUserType.Owner,
} as OrganizationUserView;
const result = service.allowResetPassword(ownerUser, customOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization cannot manage users password", () => {
const org = { ...mockOrganization, canManageUsersPassword: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization does not use reset password", () => {
const org = { ...mockOrganization, useResetPassword: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization lacks public and private keys", () => {
const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when user is not enrolled in reset password", () => {
const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView;
const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when reset password is disabled", () => {
const result = service.allowResetPassword(mockOrgUser, mockOrganization, false);
expect(result).toBe(false);
});
it("should not allow reset password when user status is not confirmed", () => {
const user = {
...mockOrgUser,
status: OrganizationUserStatusType.Invited,
} as OrganizationUserView;
const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,210 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
} from "@bitwarden/admin-console/common";
import {
OrganizationUserType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
export interface MemberActionResult {
success: boolean;
error?: string;
}
export interface BulkActionResult {
successful?: ListResponse<OrganizationUserBulkResponse>;
failed: { id: string; error: string }[];
}
@Injectable()
export class MemberActionsService {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
private accountService: AccountService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
async inviteUser(
organization: Organization,
email: string,
type: OrganizationUserType,
permissions?: any,
collections?: any[],
groups?: string[],
): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
emails: [email],
type,
accessSecretsManager: false,
collections: collections ?? [],
groups: groups ?? [],
permissions,
});
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async removeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.removeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async revokeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async deleteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async reinviteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
try {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user, publicKey),
);
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async bulkReinvite(organization: Organization, userIds: string[]): Promise<BulkActionResult> {
try {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
}
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
resetPasswordEnabled: boolean,
): boolean {
let callingUserHasPermission = false;
switch (organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
case OrganizationUserType.Admin:
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
break;
case OrganizationUserType.Custom:
callingUserHasPermission =
orgUser.type !== OrganizationUserType.Owner &&
orgUser.type !== OrganizationUserType.Admin;
break;
}
return (
organization.canManageUsersPassword &&
callingUserHasPermission &&
organization.useResetPassword &&
organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
resetPasswordEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
}

View File

@@ -0,0 +1,640 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { EntityEventsComponent } from "../../../manage/entity-events.component";
import { AccountRecoveryDialogComponent } from "../../components/account-recovery/account-recovery-dialog.component";
import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component";
import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component";
import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component";
import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component";
import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "../../components/bulk/bulk-status.component";
import {
MemberDialogComponent,
MemberDialogResult,
MemberDialogTab,
} from "../../components/member-dialog";
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
import { MemberDialogManagerService } from "./member-dialog-manager.service";
describe("MemberDialogManagerService", () => {
let service: MemberDialogManagerService;
let dialogService: MockProxy<DialogService>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let userNamePipe: MockProxy<UserNamePipe>;
let deleteManagedMemberWarningService: MockProxy<DeleteManagedMemberWarningService>;
let mockOrganization: Organization;
let mockUser: OrganizationUserView;
let mockBillingMetadata: OrganizationBillingMetadataResponse;
beforeEach(() => {
dialogService = mock<DialogService>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
userNamePipe = mock<UserNamePipe>();
deleteManagedMemberWarningService = mock<DeleteManagedMemberWarningService>();
service = new MemberDialogManagerService(
dialogService,
i18nService,
toastService,
userNamePipe,
deleteManagedMemberWarningService,
);
// Setup mock data
mockOrganization = {
id: "org-id",
canManageUsers: true,
productTierType: ProductTierType.Enterprise,
} as Organization;
mockUser = {
id: "user-id",
email: "test@example.com",
name: "Test User",
usesKeyConnector: false,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: true,
accessSecretsManager: false,
managedByOrganization: false,
} as OrganizationUserView;
mockBillingMetadata = {
organizationOccupiedSeats: 10,
isOnSecretsManagerStandalone: false,
} as OrganizationBillingMetadataResponse;
userNamePipe.transform.mockReturnValue("Test User");
});
describe("openInviteDialog", () => {
it("should open the invite dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const allUserEmails = ["user1@example.com", "user2@example.com"];
const result = await service.openInviteDialog(
mockOrganization,
mockBillingMetadata,
allUserEmails,
);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: {
kind: "Add",
organizationId: mockOrganization.id,
allOrganizationUserEmails: allUserEmails,
occupiedSeatCount: 10,
isOnSecretsManagerStandalone: false,
},
}),
);
expect(result).toBe(MemberDialogResult.Saved);
});
it("should return Canceled when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openInviteDialog(mockOrganization, mockBillingMetadata, []);
expect(result).toBe(MemberDialogResult.Canceled);
});
it("should handle null billing metadata", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
await service.openInviteDialog(mockOrganization, null, []);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
occupiedSeatCount: 0,
isOnSecretsManagerStandalone: false,
}),
}),
);
});
});
describe("openEditDialog", () => {
it("should open the edit dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: {
kind: "Edit",
name: "Test User",
organizationId: mockOrganization.id,
organizationUserId: mockUser.id,
usesKeyConnector: false,
isOnSecretsManagerStandalone: false,
initialTab: MemberDialogTab.Role,
managedByOrganization: false,
},
}),
);
expect(result).toBe(MemberDialogResult.Saved);
});
it("should use custom initial tab when provided", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
await service.openEditDialog(
mockUser,
mockOrganization,
mockBillingMetadata,
MemberDialogTab.AccountRecovery,
);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
initialTab: 0, // MemberDialogTab.AccountRecovery is 0
}),
}),
);
});
it("should return Canceled when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata);
expect(result).toBe(MemberDialogResult.Canceled);
});
});
describe("openAccountRecoveryDialog", () => {
it("should open account recovery dialog with correct parameters", async () => {
const mockDialogRef = { closed: of("recovered") };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization);
expect(dialogService.open).toHaveBeenCalledWith(
AccountRecoveryDialogComponent,
expect.objectContaining({
data: {
name: "Test User",
email: mockUser.email,
organizationId: mockOrganization.id,
organizationUserId: mockUser.id,
},
}),
);
expect(result).toBe("recovered");
});
it("should return Ok when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization);
expect(result).toBe("ok");
});
});
describe("openBulkConfirmDialog", () => {
it("should open bulk confirm dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkConfirmDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkConfirmDialogComponent,
expect.objectContaining({
data: {
organization: mockOrganization,
users: users,
},
}),
);
});
});
describe("openBulkRemoveDialog", () => {
it("should open bulk remove dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRemoveDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRemoveDialogComponent,
expect.objectContaining({
data: {
organizationId: mockOrganization.id,
users: users,
},
}),
);
});
});
describe("openBulkDeleteDialog", () => {
it("should open bulk delete dialog when warning already acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkDeleteDialogComponent,
expect.objectContaining({
data: {
organizationId: mockOrganization.id,
users: users,
},
}),
);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
});
it("should show warning before opening dialog for enterprise organizations", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(true);
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.open).toHaveBeenCalled();
});
it("should not open dialog if warning is not acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(false);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.open).not.toHaveBeenCalled();
});
it("should skip warning for non-enterprise organizations", async () => {
const nonEnterpriseOrg = {
...mockOrganization,
productTierType: ProductTierType.Free,
} as Organization;
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(nonEnterpriseOrg, users);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
expect(dialogService.open).toHaveBeenCalled();
});
});
describe("openBulkRestoreRevokeDialog", () => {
it("should open bulk restore revoke dialog with correct parameters for revoking", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRestoreRevokeDialog(mockOrganization, users, true);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRestoreRevokeComponent,
expect.objectContaining({
data: expect.objectContaining({
organizationId: mockOrganization.id,
users: users,
isRevoking: true,
}),
}),
);
});
it("should open bulk restore revoke dialog with correct parameters for restoring", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRestoreRevokeDialog(mockOrganization, users, false);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRestoreRevokeComponent,
expect.objectContaining({
data: expect.objectContaining({
organizationId: mockOrganization.id,
users: users,
isRevoking: false,
}),
}),
);
});
});
describe("openBulkEnableSecretsManagerDialog", () => {
it("should open dialog with eligible users only", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const user1 = { ...mockUser, accessSecretsManager: false } as OrganizationUserView;
const user2 = {
...mockUser,
id: "user-2",
accessSecretsManager: true,
} as OrganizationUserView;
const users = [user1, user2];
await service.openBulkEnableSecretsManagerDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkEnableSecretsManagerDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
orgId: mockOrganization.id,
users: [user1],
}),
}),
);
});
it("should show error toast when no eligible users", async () => {
i18nService.t.mockImplementation((key) => key);
const user1 = { ...mockUser, accessSecretsManager: true } as OrganizationUserView;
const users = [user1];
await service.openBulkEnableSecretsManagerDialog(mockOrganization, users);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "errorOccurred",
message: "noSelectedUsersApplicable",
});
expect(dialogService.open).not.toHaveBeenCalled();
});
});
describe("openBulkStatusDialog", () => {
it("should open bulk status dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
const filteredUsers = [mockUser];
const request = Promise.resolve();
const successMessage = "Success!";
await service.openBulkStatusDialog(users, filteredUsers, request, successMessage);
expect(dialogService.open).toHaveBeenCalledWith(
BulkStatusComponent,
expect.objectContaining({
data: {
users: users,
filteredUsers: filteredUsers,
request: request,
successfulMessage: successMessage,
},
}),
);
});
});
describe("openEventsDialog", () => {
it("should open events dialog with correct parameters", () => {
service.openEventsDialog(mockUser, mockOrganization);
expect(dialogService.open).toHaveBeenCalledWith(
EntityEventsComponent,
expect.objectContaining({
data: {
name: "Test User",
organizationId: mockOrganization.id,
entityId: mockUser.id,
showUser: false,
entity: "user",
},
}),
);
});
});
describe("openRemoveUserConfirmationDialog", () => {
it("should return true when user confirms removal", async () => {
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openRemoveUserConfirmationDialog(mockUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: {
key: "removeUserIdAccess",
placeholders: ["Test User"],
},
content: { key: "removeOrgUserConfirmation" },
type: "warning",
});
expect(result).toBe(true);
});
it("should show key connector warning when user uses key connector", async () => {
const keyConnectorUser = { ...mockUser, usesKeyConnector: true } as OrganizationUserView;
dialogService.openSimpleDialog.mockResolvedValue(true);
await service.openRemoveUserConfirmationDialog(keyConnectorUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
content: { key: "removeUserConfirmationKeyConnector" },
}),
);
});
it("should return false when user cancels confirmation", async () => {
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openRemoveUserConfirmationDialog(mockUser);
expect(result).toBe(false);
});
it("should show no master password warning for confirmed users without master password", async () => {
const noMpUser = {
...mockUser,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: false,
} as OrganizationUserView;
dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
const result = await service.openRemoveUserConfirmationDialog(noMpUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2);
expect(dialogService.openSimpleDialog).toHaveBeenLastCalledWith({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: ["Test User"],
},
type: "warning",
});
expect(result).toBe(true);
});
});
describe("openRevokeUserConfirmationDialog", () => {
it("should return true when user confirms revocation", async () => {
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openRevokeUserConfirmationDialog(mockUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "revokeAccess", placeholders: ["Test User"] },
content: "Revoke user confirmation",
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
expect(result).toBe(true);
});
it("should return false when user cancels confirmation", async () => {
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openRevokeUserConfirmationDialog(mockUser);
expect(result).toBe(false);
});
it("should show no master password warning for confirmed users without master password", async () => {
const noMpUser = {
...mockUser,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: false,
} as OrganizationUserView;
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
const result = await service.openRevokeUserConfirmationDialog(noMpUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2);
expect(result).toBe(true);
});
});
describe("openDeleteUserConfirmationDialog", () => {
it("should return true when user confirms deletion", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: {
key: "deleteOrganizationUser",
placeholders: ["Test User"],
},
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: ["Test User"],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },
});
expect(deleteManagedMemberWarningService.acknowledgeWarning).toHaveBeenCalledWith(
mockOrganization.id,
);
expect(result).toBe(true);
});
it("should show warning before deletion for enterprise organizations", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(true);
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should return false if warning is not acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(false);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("should skip warning for non-enterprise organizations", async () => {
const nonEnterpriseOrg = {
...mockOrganization,
productTierType: ProductTierType.Free,
} as Organization;
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, nonEnterpriseOrg);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should return false when user cancels confirmation", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(result).toBe(false);
expect(deleteManagedMemberWarningService.acknowledgeWarning).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,322 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { openEntityEventsDialog } from "../../../manage/entity-events.component";
import {
AccountRecoveryDialogComponent,
AccountRecoveryDialogResultType,
} from "../../components/account-recovery/account-recovery-dialog.component";
import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component";
import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component";
import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component";
import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component";
import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "../../components/bulk/bulk-status.component";
import {
MemberDialogResult,
MemberDialogTab,
openUserAddEditDialog,
} from "../../components/member-dialog";
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
@Injectable()
export class MemberDialogManagerService {
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private userNamePipe: UserNamePipe,
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
) {}
async openInviteDialog(
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
allUserEmails: string[],
): Promise<MemberDialogResult> {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Add",
organizationId: organization.id,
allOrganizationUserEmails: allUserEmails,
occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
},
});
const result = await lastValueFrom(dialog.closed);
return result ?? MemberDialogResult.Canceled;
}
async openEditDialog(
user: OrganizationUserView,
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
initialTab: MemberDialogTab = MemberDialogTab.Role,
): Promise<MemberDialogResult> {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: organization.id,
organizationUserId: user.id,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
initialTab: initialTab,
managedByOrganization: user.managedByOrganization,
},
});
const result = await lastValueFrom(dialog.closed);
return result ?? MemberDialogResult.Canceled;
}
async openAccountRecoveryDialog(
user: OrganizationUserView,
organization: Organization,
): Promise<AccountRecoveryDialogResultType> {
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user.email,
organizationId: organization.id as OrganizationId,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
return result ?? AccountRecoveryDialogResultType.Ok;
}
async openBulkConfirmDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organization: organization,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkRemoveDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkDeleteDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return;
}
}
const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkRestoreRevokeDialog(
organization: Organization,
users: OrganizationUserView[],
isRevoking: boolean,
): Promise<void> {
const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
organizationId: organization.id,
users: users,
isRevoking: isRevoking,
});
await firstValueFrom(ref.closed);
}
async openBulkEnableSecretsManagerDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const eligibleUsers = users.filter((ou) => !ou.accessSecretsManager);
if (eligibleUsers.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, {
orgId: organization.id,
users: eligibleUsers,
});
await lastValueFrom(dialogRef.closed);
}
async openBulkStatusDialog(
users: OrganizationUserView[],
filteredUsers: OrganizationUserView[],
request: Promise<any>,
successMessage: string,
): Promise<void> {
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: users,
filteredUsers: filteredUsers,
request: request,
successfulMessage: successMessage,
},
});
await lastValueFrom(dialogRef.closed);
}
openEventsDialog(user: OrganizationUserView, organization: Organization): void {
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: organization.id,
entityId: user.id,
showUser: false,
entity: "user",
},
});
}
async openRemoveUserConfirmationDialog(user: OrganizationUserView): Promise<boolean> {
const content = user.usesKeyConnector
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "removeUserIdAccess",
placeholders: [this.userNamePipe.transform(user)],
},
content: { key: content },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > 0 && user.hasMasterPassword === false) {
return await this.openNoMasterPasswordConfirmationDialog(user);
}
return true;
}
async openRevokeUserConfirmationDialog(user: OrganizationUserView): Promise<boolean> {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.i18nService.t("revokeUserConfirmation"),
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > 0 && user.hasMasterPassword === false) {
return await this.openNoMasterPasswordConfirmationDialog(user);
}
return true;
}
async openDeleteUserConfirmationDialog(
user: OrganizationUserView,
organization: Organization,
): Promise<boolean> {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return false;
}
}
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteOrganizationUser",
placeholders: [this.userNamePipe.transform(user)],
},
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },
});
if (confirmed) {
await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id);
}
return confirmed;
}
private async openNoMasterPasswordConfirmationDialog(
user: OrganizationUserView,
): Promise<boolean> {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
});
}
}

View File

@@ -0,0 +1,362 @@
import { TestBed } from "@angular/core/testing";
import {
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { GroupApiService } from "../../../core";
import { OrganizationMembersService } from "./organization-members.service";
describe("OrganizationMembersService", () => {
let service: OrganizationMembersService;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let groupService: jest.Mocked<GroupApiService>;
let apiService: jest.Mocked<ApiService>;
const mockOrganizationId = "org-123" as OrganizationId;
const createMockOrganization = (overrides: Partial<Organization> = {}): Organization => {
const org = new Organization();
org.id = mockOrganizationId;
org.useGroups = false;
return Object.assign(org, overrides);
};
const createMockUserResponse = (
overrides: Partial<OrganizationUserUserDetailsResponse> = {},
): OrganizationUserUserDetailsResponse => {
return {
id: "user-1",
userId: "user-id-1",
email: "test@example.com",
name: "Test User",
collections: [],
groups: [],
...overrides,
} as OrganizationUserUserDetailsResponse;
};
const createMockGroup = (id: string, name: string) => ({
id,
name,
});
const createMockCollection = (id: string, name: string) => ({
id,
name,
});
beforeEach(() => {
organizationUserApiService = {
getAllUsers: jest.fn(),
} as any;
groupService = {
getAll: jest.fn(),
} as any;
apiService = {
getCollections: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationMembersService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: GroupApiService, useValue: groupService },
{ provide: ApiService, useValue: apiService },
],
});
service = TestBed.inject(OrganizationMembersService);
});
describe("loadUsers", () => {
it("should load users with collections when organization does not use groups", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [createMockCollection("col-1", "Collection 1")];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, {
includeGroups: false,
includeCollections: true,
});
expect(apiService.getCollections).toHaveBeenCalledWith(mockOrganizationId);
expect(groupService.getAll).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].collectionNames).toEqual(["Collection 1"]);
expect(result[0].groupNames).toEqual([]);
});
it("should load users with groups when organization uses groups", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
createMockGroup("group-2", "Group 2"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, {
includeGroups: true,
includeCollections: false,
});
expect(groupService.getAll).toHaveBeenCalledWith(mockOrganizationId);
expect(apiService.getCollections).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].groupNames).toEqual(["Group 1", "Group 2"]);
expect(result[0].collectionNames).toEqual([]);
});
it("should sort group names alphabetically", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2", "group-3"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Zebra Group"),
createMockGroup("group-2", "Alpha Group"),
createMockGroup("group-3", "Beta Group"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result[0].groupNames).toEqual(["Alpha Group", "Beta Group", "Zebra Group"]);
});
it("should sort collection names alphabetically", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [
createMockCollection("col-1", "Zebra Collection"),
createMockCollection("col-2", "Alpha Collection"),
createMockCollection("col-3", "Beta Collection"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(result[0].collectionNames).toEqual([
"Alpha Collection",
"Beta Collection",
"Zebra Collection",
]);
});
it("should filter out null or undefined group names", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2", "group-3"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
// group-2 is missing - should be filtered out
createMockGroup("group-3", "Group 3"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result[0].groupNames).toEqual(["Group 1", "Group 3"]);
expect(result[0].groupNames).not.toContain(undefined);
expect(result[0].groupNames).not.toContain(null);
});
it("should filter out null or undefined collection names", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [
createMockCollection("col-1", "Collection 1"),
// col-2 is missing - should be filtered out
createMockCollection("col-3", "Collection 3"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(result[0].collectionNames).toEqual(["Collection 1", "Collection 3"]);
expect(result[0].collectionNames).not.toContain(undefined);
expect(result[0].collectionNames).not.toContain(null);
});
it("should handle multiple users", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser1 = createMockUserResponse({
id: "user-1",
groups: ["group-1"],
});
const mockUser2 = createMockUserResponse({
id: "user-2",
groups: ["group-2"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser1, mockUser2],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
createMockGroup("group-2", "Group 2"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(2);
expect(result[0].groupNames).toEqual(["Group 1"]);
expect(result[1].groupNames).toEqual(["Group 2"]);
});
it("should return empty array when usersResponse.data is null", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: null as any,
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toEqual([]);
});
it("should return empty array when usersResponse.data is undefined", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: undefined as any,
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toEqual([]);
});
it("should handle empty groups array", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: [],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue([]);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(1);
expect(result[0].groupNames).toEqual([]);
});
it("should handle empty collections array", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(1);
expect(result[0].collectionNames).toEqual([]);
});
it("should fetch data in parallel using Promise.all", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [],
} as any;
let getUsersCallTime: number;
let getGroupsCallTime: number;
organizationUserApiService.getAllUsers.mockImplementation(async () => {
getUsersCallTime = Date.now();
return mockUsersResponse;
});
groupService.getAll.mockImplementation(async () => {
getGroupsCallTime = Date.now();
return [];
});
await service.loadUsers(organization);
// Both calls should have been initiated at roughly the same time (within 50ms)
expect(Math.abs(getUsersCallTime - getGroupsCallTime)).toBeLessThan(50);
});
});
});

View File

@@ -0,0 +1,76 @@
import { Injectable } from "@angular/core";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { GroupApiService } from "../../../core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@Injectable()
export class OrganizationMembersService {
constructor(
private organizationUserApiService: OrganizationUserApiService,
private groupService: GroupApiService,
private apiService: ApiService,
) {}
async loadUsers(organization: Organization): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>> | undefined;
let collectionsPromise: Promise<Map<string, string>> | undefined;
const userPromise = this.organizationUserApiService.getAllUsers(organization.id, {
includeGroups: organization.useGroups,
includeCollections: !organization.useGroups,
});
if (organization.useGroups) {
groupsPromise = this.getGroupNameMap(organization);
} else {
collectionsPromise = this.getCollectionNameMap(organization);
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
userPromise,
groupsPromise,
collectionsPromise,
]);
return (
usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g: string) => groupNamesMap?.get(g))
.filter((name): name is string => name != null)
.sort();
userView.collectionNames = userView.collections
.map((c: { id: string }) => collectionNamesMap?.get(c.id))
.filter((name): name is string => name != null)
.sort();
return userView;
}) ?? []
);
}
private 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: { id: string; name: string }) => groupNameMap.set(g.id, g.name));
return groupNameMap;
}
private async getCollectionNameMap(organization: Organization): Promise<Map<string, string>> {
const response = this.apiService
.getCollections(organization.id)
.then((res) =>
res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })),
);
const collections = await response;
const collectionMap = new Map<string, string>();
collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name));
return collectionMap;
}
}

View File

@@ -78,7 +78,11 @@ export abstract class BasePolicyEditDefinition {
*/
@Directive()
export abstract class BasePolicyEditComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() policyResponse: PolicyResponse | undefined;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() policy: BasePolicyEditDefinition | undefined;
/**

View File

@@ -37,6 +37,8 @@ import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "policies.component.html",
imports: [SharedModule, HeaderModule],

View File

@@ -18,6 +18,8 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio
return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype);
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "autotype-policy.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ export class DisableSendPolicy extends BasePolicyEditDefinition {
component = DisableSendPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "disable-send.component.html",
imports: [SharedModule],

View File

@@ -26,6 +26,8 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
component = MasterPasswordPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "master-password.component.html",
imports: [SharedModule],

View File

@@ -22,6 +22,8 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "organization-data-ownership.component.html",
imports: [SharedModule],

View File

@@ -19,6 +19,8 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition {
component = PasswordGeneratorPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "password-generator.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition {
component = RemoveUnlockWithPinPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "remove-unlock-with-pin.component.html",
imports: [SharedModule],

View File

@@ -19,6 +19,8 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "require-sso.component.html",
imports: [SharedModule],

View File

@@ -26,6 +26,8 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "reset-password.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition {
component = RestrictedItemTypesPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "restricted-item-types.component.html",
imports: [SharedModule],

View File

@@ -13,6 +13,8 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition {
component = SendOptionsPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "send-options.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition {
component = SingleOrgPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "single-org.component.html",
imports: [SharedModule],

View File

@@ -12,6 +12,8 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition {
component = TwoFactorAuthenticationPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "two-factor-authentication.component.html",
imports: [SharedModule],

View File

@@ -34,6 +34,8 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "vnext-organization-data-ownership.component.html",
imports: [SharedModule],
@@ -50,6 +52,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
super();
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {

View File

@@ -45,11 +45,15 @@ export type PolicyEditDialogData = {
export type PolicyEditDialogResult = "saved";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "policy-edit-dialog.component.html",
imports: [SharedModule],
})
export class PolicyEditDialogComponent implements AfterViewInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("policyForm", { read: ViewContainerRef, static: true })
policyFormRef: ViewContainerRef | undefined;

View File

@@ -14,6 +14,8 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ReportVariant, reports, ReportType, ReportEntry } from "../../../dirt/reports";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-reports-home",
templateUrl: "reports-home.component.html",

View File

@@ -38,6 +38,8 @@ import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.compone
import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-account",
templateUrl: "account.component.html",

View File

@@ -78,6 +78,8 @@ export enum DeleteOrganizationDialogResult {
Canceled = "canceled",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-delete-organization",
imports: [SharedModule, UserVerificationModule],

View File

@@ -26,6 +26,8 @@ import { TwoFactorSetupDuoComponent } from "../../../auth/settings/two-factor/tw
import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor/two-factor-setup.component";
import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor/two-factor-verify.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup",
templateUrl: "../../../auth/settings/two-factor/two-factor-setup.component.html",

View File

@@ -45,6 +45,8 @@ export enum PermissionMode {
Edit = "edit",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-access-selector",
templateUrl: "access-selector.component.html",
@@ -139,6 +141,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
/**
* List of all selectable items that. Sorted internally.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
get items(): AccessItemView[] {
return this.selectionList.allItems;
@@ -160,6 +164,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
/**
* Permission mode that controls if the permission form controls and column should be present.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
get permissionMode(): PermissionMode {
return this._permissionMode;
@@ -175,41 +181,64 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
/**
* Column header for the selected items table
*/
@Input() columnHeader: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
columnHeader: string;
/**
* Label used for the ng selector
*/
@Input() selectorLabelText: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
selectorLabelText: string;
/**
* Helper text displayed under the ng selector
*/
@Input() selectorHelpText: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
selectorHelpText: string;
/**
* Text that is shown in the table when no items are selected
*/
@Input() emptySelectionText: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
emptySelectionText: string;
/**
* Flag for if the member roles column should be present
*/
@Input() showMemberRoles: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
showMemberRoles: boolean;
/**
* Flag for if the group column should be present
*/
@Input() showGroupColumn: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
showGroupColumn: boolean;
/**
* Hide the multi-select so that new items cannot be added
*/
@Input() hideMultiSelect = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
hideMultiSelect = false;
/**
* The initial permission that will be selected in the dialog, defaults to View.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
protected initialPermission: CollectionPermission = CollectionPermission.View;

View File

@@ -116,6 +116,8 @@ export enum CollectionDialogAction {
Upgrade = "upgrade",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "collection-dialog.component.html",
imports: [SharedModule, AccessSelectorModule, SelectModule],

View File

@@ -18,6 +18,8 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
* "Bitwarden allows all members of Enterprise Organizations to redeem a complimentary Families Plan with their
* personal email address." - https://bitwarden.com/learning/free-families-plan-for-enterprise/
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "accept-family-sponsorship.component.html",
imports: [CommonModule, I18nPipe, IconModule],

View File

@@ -28,11 +28,15 @@ import {
openDeleteOrganizationDialog,
} from "../settings/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "families-for-enterprise-setup.component.html",
imports: [SharedModule, OrganizationPlansComponent],
})
export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(OrganizationPlansComponent, { static: false })
set organizationPlansComponent(value: OrganizationPlansComponent) {
if (!value) {

View File

@@ -11,6 +11,8 @@ import { OrganizationPlansComponent } from "../../billing";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "create-organization.component.html",
imports: [SharedModule, OrganizationPlansComponent, HeaderModule],

View File

@@ -0,0 +1,461 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../organizations/change-plan-dialog.component";
import { BillingConstraintService, SeatLimitResult } from "./billing-constraint.service";
jest.mock("../../organizations/change-plan-dialog.component");
describe("BillingConstraintService", () => {
let service: BillingConstraintService;
let i18nService: jest.Mocked<I18nService>;
let dialogService: jest.Mocked<DialogService>;
let toastService: jest.Mocked<ToastService>;
let router: jest.Mocked<Router>;
let organizationMetadataService: jest.Mocked<OrganizationMetadataServiceAbstraction>;
const mockOrganizationId = "org-123" as OrganizationId;
const createMockOrganization = (overrides: Partial<Organization> = {}): Organization => {
const org = new Organization();
org.id = mockOrganizationId;
org.seats = 10;
org.productTierType = ProductTierType.Teams;
Object.defineProperty(org, "hasReseller", {
value: false,
writable: true,
configurable: true,
});
Object.defineProperty(org, "canEditSubscription", {
value: true,
writable: true,
configurable: true,
});
return Object.assign(org, overrides);
};
const createMockBillingMetadata = (
overrides: Partial<OrganizationBillingMetadataResponse> = {},
): OrganizationBillingMetadataResponse => {
return {
organizationOccupiedSeats: 5,
...overrides,
} as OrganizationBillingMetadataResponse;
};
beforeEach(() => {
const mockDialogRef = {
closed: of(true),
};
const mockSimpleDialogRef = {
closed: of(true),
};
i18nService = {
t: jest.fn().mockReturnValue("translated-text"),
} as any;
dialogService = {
openSimpleDialogRef: jest.fn().mockReturnValue(mockSimpleDialogRef),
} as any;
toastService = {
showToast: jest.fn(),
} as any;
router = {
navigate: jest.fn().mockResolvedValue(true),
} as any;
organizationMetadataService = {
getOrganizationMetadata$: jest.fn(),
refreshMetadataCache: jest.fn(),
} as any;
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
TestBed.configureTestingModule({
providers: [
BillingConstraintService,
{ provide: I18nService, useValue: i18nService },
{ provide: DialogService, useValue: dialogService },
{ provide: ToastService, useValue: toastService },
{ provide: Router, useValue: router },
{ provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService },
],
});
service = TestBed.inject(BillingConstraintService);
});
describe("checkSeatLimit", () => {
it("should allow users when occupied seats are less than total seats", () => {
const organization = createMockOrganization({ seats: 10 });
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 5 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({ canAddUsers: true });
});
it("should allow users when occupied seats equal total seats for non-fixed seat plans", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.Teams,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({ canAddUsers: true });
});
it("should block users with reseller-limit reason when organization has reseller", () => {
const organization = createMockOrganization({
seats: 10,
hasReseller: true,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "reseller-limit",
});
});
it("should block users with fixed-seat-limit reason for fixed seat plans", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.Free,
canEditSubscription: true,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
});
});
it("should not show upgrade dialog when organization cannot edit subscription", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.TeamsStarter,
canEditSubscription: false,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
});
});
it("shoud throw if missing billingMetadata", () => {
const organization = createMockOrganization({ seats: 10 });
const billingMetadata = createMockBillingMetadata({
organizationOccupiedSeats: undefined as any,
});
const err = () => service.checkSeatLimit(organization, billingMetadata);
expect(err).toThrow("Cannot check seat limit: billingMetadata is null or undefined.");
});
});
describe("seatLimitReached", () => {
it("should return false when canAddUsers is true", async () => {
const result: SeatLimitResult = { canAddUsers: true };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(false);
});
it("should show toast and return true for reseller-limit", async () => {
const result: SeatLimitResult = { canAddUsers: false, reason: "reseller-limit" };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "translated-text",
message: "translated-text",
});
expect(i18nService.t).toHaveBeenCalledWith("seatLimitReached");
expect(i18nService.t).toHaveBeenCalledWith("contactYourProvider");
expect(seatLimitReached).toBe(true);
});
it("should return true when upgrade dialog is cancelled", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
};
const organization = createMockOrganization();
const mockDialogRef = { closed: of(ChangePlanDialogResultType.Closed) };
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, {
data: {
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
expect(seatLimitReached).toBe(true);
});
it("should return false when upgrade dialog is submitted", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
};
const organization = createMockOrganization();
const mockDialogRef = { closed: of(ChangePlanDialogResultType.Submitted) };
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(false);
});
it("should show seat limit dialog when shouldShowUpgradeDialog is false", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: false,
productTierType: ProductTierType.Free,
});
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(dialogService.openSimpleDialogRef).toHaveBeenCalled();
expect(seatLimitReached).toBe(true);
});
it("should return true for unknown reasons", async () => {
const result: SeatLimitResult = { canAddUsers: false };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(true);
});
});
describe("navigateToPaymentMethod", () => {
it("should navigate to payment method with correct parameters", async () => {
const organization = createMockOrganization();
await service.navigateToPaymentMethod(organization);
expect(router.navigate).toHaveBeenCalledWith(
["organizations", organization.id, "billing", "payment-method"],
{
state: { launchPaymentModalAutomatically: true },
},
);
});
});
describe("private methods through public method coverage", () => {
describe("getDialogContent via showSeatLimitReachedDialog", () => {
it("should get correct dialog content for Free organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Free,
canEditSubscription: false,
seats: 5,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("freeOrgInvLimitReachedNoManageBilling", 5);
});
it("should get correct dialog content for TeamsStarter organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.TeamsStarter,
canEditSubscription: false,
seats: 3,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith(
"teamsStarterPlanInvLimitReachedNoManageBilling",
3,
);
});
it("should get correct dialog content for Families organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Families,
canEditSubscription: false,
seats: 6,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("familiesPlanInvLimitReachedNoManageBilling", 6);
});
it("should throw error for unsupported product type in getProductKey", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Enterprise,
canEditSubscription: false,
});
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
describe("getAcceptButtonText via showSeatLimitReachedDialog", () => {
it("should return 'ok' when organization cannot edit subscription", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: false,
productTierType: ProductTierType.Free,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("ok");
});
it("should return 'upgrade' when organization can edit subscription", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Free,
});
const mockSimpleDialogRef = { closed: of(false) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("upgrade");
});
it("should throw error for unsupported product type in getAcceptButtonText", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
});
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
describe("handleUpgradeNavigation", () => {
it("should navigate to billing subscription with upgrade query param", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Free,
});
const mockSimpleDialogRef = { closed: of(true) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await service.seatLimitReached(result, organization);
expect(router.navigate).toHaveBeenCalledWith(
["/organizations", organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } },
);
});
it("should throw error for non-self-upgradable product type", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
});
const mockSimpleDialogRef = { closed: of(true) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
});
});

View File

@@ -0,0 +1,192 @@
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { isFixedSeatPlan } from "../../../admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../organizations/change-plan-dialog.component";
export interface SeatLimitResult {
canAddUsers: boolean;
reason?: "reseller-limit" | "fixed-seat-limit" | "no-billing-permission";
shouldShowUpgradeDialog?: boolean;
}
@Injectable()
export class BillingConstraintService {
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
private toastService: ToastService,
private router: Router,
) {}
checkSeatLimit(
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
): SeatLimitResult {
const occupiedSeats = billingMetadata?.organizationOccupiedSeats;
if (occupiedSeats == null) {
throw new Error("Cannot check seat limit: billingMetadata is null or undefined.");
}
const totalSeats = organization.seats;
if (occupiedSeats < totalSeats) {
return { canAddUsers: true };
}
if (organization.hasReseller) {
return {
canAddUsers: false,
reason: "reseller-limit",
};
}
if (isFixedSeatPlan(organization.productTierType)) {
return {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: organization.canEditSubscription,
};
}
return { canAddUsers: true };
}
async seatLimitReached(result: SeatLimitResult, organization: Organization): Promise<boolean> {
if (result.canAddUsers) {
return false;
}
switch (result.reason) {
case "reseller-limit":
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
message: this.i18nService.t("contactYourProvider"),
});
return true;
case "fixed-seat-limit":
if (result.shouldShowUpgradeDialog) {
const dialogResult = await this.showChangePlanDialog(organization);
// If the plan was successfully changed, the seat limit is no longer blocking
return dialogResult !== ChangePlanDialogResultType.Submitted;
} else {
await this.showSeatLimitReachedDialog(organization);
return true;
}
default:
return true;
}
}
private async showChangePlanDialog(
organization: Organization,
): Promise<ChangePlanDialogResultType> {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result == null) {
throw new Error("ChangePlanDialog result is null or undefined.");
}
return result;
}
private async showSeatLimitReachedDialog(organization: Organization): Promise<void> {
const dialogContent = this.getSeatLimitReachedDialogContent(organization);
const acceptButtonText = this.getSeatLimitReachedDialogAcceptButtonText(organization);
const orgUpgradeSimpleDialogOpts = {
title: this.i18nService.t("upgradeOrganization"),
content: dialogContent,
type: "primary" as const,
acceptButtonText,
cancelButtonText: organization.canEditSubscription ? undefined : (null as string | null),
};
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
const result = await lastValueFrom(simpleDialog.closed);
if (result && organization.canEditSubscription) {
await this.handleUpgradeNavigation(organization);
}
}
private async handleUpgradeNavigation(organization: Organization): Promise<void> {
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
private getSeatLimitReachedDialogContent(organization: Organization): string {
const productKey = this.getProductKey(organization);
return this.i18nService.t(productKey, organization.seats);
}
private getSeatLimitReachedDialogAcceptButtonText(organization: Organization): string {
if (!organization.canEditSubscription) {
return this.i18nService.t("ok");
}
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${productType}`);
}
return this.i18nService.t("upgrade");
}
private getProductKey(organization: Organization): string {
const manageBillingText = organization.canEditSubscription
? "ManageBilling"
: "NoManageBilling";
let product = "";
switch (organization.productTierType) {
case ProductTierType.Free:
product = "freeOrg";
break;
case ProductTierType.TeamsStarter:
product = "teamsStarterPlan";
break;
case ProductTierType.Families:
product = "familiesPlan";
break;
default:
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
return `${product}InvLimitReached${manageBillingText}`;
}
async navigateToPaymentMethod(organization: Organization): Promise<void> {
await this.router.navigate(
["organizations", `${organization.id}`, "billing", "payment-method"],
{
state: { launchPaymentModalAutomatically: true },
},
);
}
}

View File

@@ -842,10 +842,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
);
const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization };
await Promise.all([
this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null),
this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress),
]);
// These need to be synchronous so one of them can create the Customer in the case we're upgrading from Free.
await this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress);
await this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null);
}
// Backfill pub/priv key if necessary

View File

@@ -24,6 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-device-approvals",
templateUrl: "./device-approvals.component.html",

View File

@@ -22,6 +22,8 @@ export interface DomainAddEditDialogData {
existingDomainNames: Array<string>;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "domain-add-edit-dialog.component.html",
standalone: false,

View File

@@ -33,6 +33,8 @@ import {
DomainAddEditDialogData,
} from "./domain-add-edit-dialog/domain-add-edit-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-manage-domain-verification",
templateUrl: "domain-verification.component.html",

View File

@@ -21,6 +21,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-org-manage-scim",
templateUrl: "scim.component.html",

View File

@@ -21,6 +21,8 @@ export class ActivateAutofillPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "activate-autofill.component.html",
imports: [SharedModule],

View File

@@ -17,6 +17,8 @@ export class AutomaticAppLoginPolicy extends BasePolicyEditDefinition {
component = AutomaticAppLoginPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "automatic-app-login.component.html",
imports: [SharedModule],

View File

@@ -14,6 +14,8 @@ export class DisablePersonalVaultExportPolicy extends BasePolicyEditDefinition {
component = DisablePersonalVaultExportPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "disable-personal-vault-export.component.html",
imports: [SharedModule],

View File

@@ -20,6 +20,8 @@ export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition {
component = MaximumVaultTimeoutPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "maximum-vault-timeout.component.html",
imports: [SharedModule],

View File

@@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-accept-provider",
templateUrl: "accept-provider.component.html",

View File

@@ -33,6 +33,8 @@ export enum AddEditMemberDialogResultType {
Saved = "saved",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "add-edit-member-dialog.component.html",
standalone: false,

View File

@@ -26,6 +26,8 @@ type BulkConfirmDialogParams = {
users: BulkUserDetails[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl:
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html",

View File

@@ -16,6 +16,8 @@ type BulkRemoveDialogParams = {
users: BulkUserDetails[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl:
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html",

View File

@@ -20,6 +20,8 @@ import { BaseEventsComponent } from "@bitwarden/web-vault/app/admin-console/comm
import { EventService } from "@bitwarden/web-vault/app/core";
import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "provider-events",
templateUrl: "events.component.html",

View File

@@ -30,6 +30,7 @@ import {
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import {
AddEditMemberDialogComponent,
@@ -45,6 +46,8 @@ class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
protected statusType = ProviderUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "members.component.html",
standalone: false,
@@ -199,16 +202,27 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
}
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<void> {
const providerKey = await this.keyService.getProviderKey(this.providerId);
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
const request = new ProviderUserConfirmRequest();
request.key = key.encryptedString;
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<MemberActionResult> {
try {
const providerKey = await this.keyService.getProviderKey(this.providerId);
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
const request = new ProviderUserConfirmRequest();
request.key = key.encryptedString;
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
removeUser = (id: string): Promise<void> =>
this.apiService.deleteProviderUser(this.providerId, id);
removeUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.deleteProviderUser(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
edit = async (user: ProviderUser | null): Promise<void> => {
const data: AddEditMemberDialogParams = {
@@ -251,6 +265,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
getUsers = (): Promise<ListResponse<ProviderUser>> =>
this.apiService.getProviderUsers(this.providerId);
reinviteUser = (id: string): Promise<void> =>
this.apiService.postProviderUserReinvite(this.providerId, id);
reinviteUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.postProviderUserReinvite(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
}

View File

@@ -23,6 +23,8 @@ import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.mod
import { ProviderWarningsService } from "../../billing/providers/warnings/services";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "providers-layout",
templateUrl: "providers-layout.component.html",

View File

@@ -10,6 +10,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-providers",
templateUrl: "providers.component.html",

View File

@@ -17,6 +17,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "provider-account",
templateUrl: "account.component.html",

View File

@@ -4,6 +4,8 @@ import { Params } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/assets/svg";
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-setup-provider",
templateUrl: "setup-provider.component.html",

View File

@@ -20,12 +20,16 @@ import {
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "provider-setup",
templateUrl: "setup.component.html",
standalone: false,
})
export class SetupComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
loading = true;

View File

@@ -10,6 +10,8 @@ import { ProviderVerifyRecoverDeleteRequest } from "@bitwarden/common/admin-cons
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete-provider",
templateUrl: "verify-recover-delete-provider.component.html",

View File

@@ -208,7 +208,7 @@ describe("DefaultOrganizationMetadataService", () => {
}, 10);
});
it("does not trigger refresh when feature flag is disabled", async () => {
it("does trigger refresh when feature flag is disabled", async () => {
featureFlagSubject.next(false);
const mockResponse1 = createMockMetadataResponse(false, 10);
@@ -232,11 +232,10 @@ describe("DefaultOrganizationMetadataService", () => {
service.refreshMetadataCache();
// wait to ensure no additional invocations
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(1);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
expect(invocationCount).toBe(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});

View File

@@ -1,4 +1,4 @@
import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
@@ -18,57 +18,56 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
) {}
private refreshMetadataTrigger = new Subject<void>();
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
refreshMetadataCache = () => this.refreshMetadataTrigger.next();
refreshMetadataCache = () => {
this.metadataCache.clear();
this.refreshMetadataTrigger.next();
};
getOrganizationMetadata$ = (
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> =>
this.configService
.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
.pipe(
switchMap((featureFlagEnabled) => {
return merge(
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
this.refreshMetadataTrigger.pipe(
filter(() => featureFlagEnabled),
switchMap(() =>
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
),
),
);
}),
);
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
return combineLatest([
this.refreshMetadataTrigger,
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
]).pipe(
switchMap(([_, featureFlagEnabled]) =>
featureFlagEnabled
? this.vNextGetOrganizationMetadataInternal$(orgId)
: this.getOrganizationMetadataInternal$(orgId),
),
);
}
private getOrganizationMetadataInternal$(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
bypassCache: boolean = false,
private vNextGetOrganizationMetadataInternal$(
orgId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
return this.metadataCache.get(organizationId)!;
const cacheHit = this.metadataCache.get(orgId);
if (cacheHit) {
return cacheHit;
}
const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
const result = from(this.fetchMetadata(orgId, true)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
if (featureFlagEnabled) {
this.metadataCache.set(organizationId, metadata$);
}
this.metadataCache.set(orgId, result);
return result;
}
return metadata$;
private getOrganizationMetadataInternal$(
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
return from(this.fetchMetadata(organizationId, false)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
}
private async fetchMetadata(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
if (featureFlagEnabled) {
return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
}
return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
return featureFlagEnabled
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}