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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {}
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user