mirror of
https://github.com/bitwarden/browser
synced 2026-02-20 19:34:03 +00:00
Merge main
This commit is contained in:
@@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { ReplaySubject } from "rxjs";
|
||||
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentService,
|
||||
@@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => {
|
||||
isCloud: () => false,
|
||||
} as Environment);
|
||||
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()),
|
||||
} as any;
|
||||
|
||||
const mockEnvironmentService = {
|
||||
environment$: environmentSubject.asObservable(),
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
],
|
||||
providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }],
|
||||
});
|
||||
|
||||
dataSource = TestBed.runInInjectionContext(
|
||||
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
|
||||
() => new TestPeopleTableDataSource(mockEnvironmentService),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { computed, Signal } from "@angular/core";
|
||||
import { Signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map } from "rxjs";
|
||||
import { Observable, Subject, map } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
ProviderUserStatusType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
|
||||
import { StatusType, UserViewTypes } from "./base-members.component";
|
||||
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
|
||||
|
||||
export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
|
||||
|
||||
export type UserViewTypes = ProviderUser | OrganizationUserView;
|
||||
export type ProviderUser = ProviderUserUserDetailsResponse;
|
||||
|
||||
/**
|
||||
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
|
||||
@@ -21,8 +25,7 @@ import { StatusType, UserViewTypes } from "./base-members.component";
|
||||
export const MaxCheckedCount = 500;
|
||||
|
||||
/**
|
||||
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
|
||||
* feature flag is enabled on cloud environments.
|
||||
* Maximum for bulk reinvite limit in cloud environments.
|
||||
*/
|
||||
export const CloudBulkReinviteLimit = 8000;
|
||||
|
||||
@@ -72,18 +75,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
confirmedUserCount: number;
|
||||
revokedUserCount: number;
|
||||
|
||||
/** True when increased bulk limit feature is enabled (feature flag + cloud environment) */
|
||||
/** True when increased bulk limit feature is enabled (cloud environment) */
|
||||
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
|
||||
|
||||
constructor(configService: ConfigService, environmentService: EnvironmentService) {
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
super();
|
||||
|
||||
const featureFlagEnabled = toSignal(
|
||||
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
this.isIncreasedBulkLimitEnabled = toSignal(
|
||||
environmentService.environment$.pipe(map((env) => env.isCloud())),
|
||||
);
|
||||
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
|
||||
|
||||
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
|
||||
}
|
||||
|
||||
override set data(data: T[]) {
|
||||
@@ -100,6 +100,8 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
this.data?.filter((u) => u.status === this.statusType.Confirmed).length ?? 0;
|
||||
this.revokedUserCount =
|
||||
this.data?.filter((u) => u.status === this.statusType.Revoked).length ?? 0;
|
||||
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
override get data() {
|
||||
@@ -112,6 +114,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
* @param select check the user (true), uncheck the user (false), or toggle the current state (null)
|
||||
*/
|
||||
checkUser(user: T, select?: boolean) {
|
||||
this.setUserChecked(user, select);
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to set checked state without triggering emissions.
|
||||
* Use this in bulk operations to avoid excessive emissions.
|
||||
*/
|
||||
private setUserChecked(user: T, select?: boolean) {
|
||||
(user as any).checked = select == null ? !(user as any).checked : select;
|
||||
}
|
||||
|
||||
@@ -119,6 +130,12 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
return this.data.filter((u) => (u as any).checked);
|
||||
}
|
||||
|
||||
private checkedUsersUpdated$ = new Subject<void>();
|
||||
|
||||
usersUpdated(): Observable<T[]> {
|
||||
return this.checkedUsersUpdated$.asObservable().pipe(map(() => this.getCheckedUsers()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets checked users in the order they appear in the filtered/sorted table view.
|
||||
* Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked).
|
||||
@@ -147,8 +164,10 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
: Math.min(filteredUsers.length, MaxCheckedCount);
|
||||
|
||||
for (let i = 0; i < selectCount; i++) {
|
||||
this.checkUser(filteredUsers[i], select);
|
||||
this.setUserChecked(filteredUsers[i], select);
|
||||
}
|
||||
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
uncheckAllUsers() {
|
||||
@@ -190,18 +209,18 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
}
|
||||
|
||||
// Uncheck users beyond the limit
|
||||
users.slice(limit).forEach((user) => this.checkUser(user, false));
|
||||
users.slice(limit).forEach((user) => this.setUserChecked(user, false));
|
||||
|
||||
// Emit once after all unchecking is done
|
||||
this.checkedUsersUpdated$.next();
|
||||
|
||||
return users.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag.
|
||||
* Returns checked users in visible order, optionally limited to the specified count.
|
||||
*
|
||||
* When the feature flag is enabled: Returns checked users in visible order, limited to the specified count.
|
||||
* When the feature flag is disabled: Returns all checked users without applying any limit.
|
||||
*
|
||||
* @param limit The maximum number of users to return (only applied when feature flag is enabled)
|
||||
* @param limit The maximum number of users to return
|
||||
* @returns The checked users array
|
||||
*/
|
||||
getCheckedUsersWithLimit(limit: number): T[] {
|
||||
@@ -213,3 +232,26 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ProvidersTableDataSource extends PeopleTableDataSource<ProviderUser> {
|
||||
protected statusType = ProviderUserStatusType;
|
||||
}
|
||||
|
||||
export class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||
protected statusType = OrganizationUserStatusType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if the confirm users banner should be shown
|
||||
* @params dataSource Either a ProvidersTableDataSource or a MembersTableDataSource
|
||||
*/
|
||||
export function showConfirmBanner(
|
||||
dataSource: ProvidersTableDataSource | MembersTableDataSource,
|
||||
): boolean {
|
||||
return (
|
||||
dataSource.activeUserCount > 1 &&
|
||||
dataSource.confirmedUserCount > 0 &&
|
||||
dataSource.confirmedUserCount < 3 &&
|
||||
dataSource.acceptedUserCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CollectionId } from "@bitwarden/sdk-internal";
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./utils";
|
||||
export * from "./collection-badge";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
@Pipe({
|
||||
name: "collectionNameFromId",
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
|
||||
|
||||
describe("CollectionUtils Service", () => {
|
||||
describe("getNestedCollectionTree", () => {
|
||||
it("should return collections properly sorted if provided out of order", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
const childCollection = new CollectionView({
|
||||
name: "Parent/Child",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
collections.push(childCollection);
|
||||
collections.push(parentCollection);
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result[0].node.name).toBe("Parent");
|
||||
expect(result[0].children[0].node.name).toBe("Child");
|
||||
});
|
||||
|
||||
it("should return an empty array if no collections are provided", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFlatCollectionTree", () => {
|
||||
it("should flatten a tree node with no children", () => {
|
||||
// Arrange
|
||||
const collection = new CollectionView({
|
||||
name: "Test Collection",
|
||||
id: "test-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [
|
||||
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe(collection);
|
||||
});
|
||||
|
||||
it("should flatten a tree node with children", () => {
|
||||
// Arrange
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
id: "parent-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child1Collection = new CollectionView({
|
||||
name: "Child 1",
|
||||
id: "child1-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child2Collection = new CollectionView({
|
||||
name: "Child 2",
|
||||
id: "child2-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const grandchildCollection = new CollectionView({
|
||||
name: "Grandchild",
|
||||
id: "grandchild-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const parentNode = new TreeNode<CollectionView>(
|
||||
parentCollection,
|
||||
{} as TreeNode<CollectionView>,
|
||||
);
|
||||
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
|
||||
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
|
||||
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
|
||||
|
||||
parentNode.children = [child1Node, child2Node];
|
||||
child1Node.children = [grandchildNode];
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(4);
|
||||
expect(result[0]).toBe(parentCollection);
|
||||
expect(result).toContain(child1Collection);
|
||||
expect(result).toContain(child2Collection);
|
||||
expect(result).toContain(grandchildCollection);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionAdminView,
|
||||
CollectionView,
|
||||
NestingDelimiter,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
|
||||
export function getNestedCollectionTree(
|
||||
collections: CollectionAdminView[],
|
||||
): TreeNode<CollectionAdminView>[];
|
||||
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
|
||||
export function getNestedCollectionTree(
|
||||
collections: (CollectionView | CollectionAdminView)[],
|
||||
): TreeNode<CollectionView | CollectionAdminView>[] {
|
||||
if (!collections) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
|
||||
// modifies the names of collections.
|
||||
// These changes risk affecting collections store in StateService.
|
||||
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cloneCollection);
|
||||
|
||||
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
|
||||
clonedCollections.map((c) => {
|
||||
const key = c.organizationId;
|
||||
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
|
||||
});
|
||||
for (const group of groupedByOrg.values()) {
|
||||
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
for (const c of group) {
|
||||
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
|
||||
}
|
||||
all.push(...nodes);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export function cloneCollection(collection: CollectionView): CollectionView;
|
||||
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
|
||||
export function cloneCollection(
|
||||
collection: CollectionView | CollectionAdminView,
|
||||
): CollectionView | CollectionAdminView {
|
||||
let cloned;
|
||||
|
||||
if (collection instanceof CollectionAdminView) {
|
||||
cloned = Object.assign(
|
||||
new CollectionAdminView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
} else {
|
||||
cloned = Object.assign(
|
||||
new CollectionView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionAdminView>[],
|
||||
): CollectionAdminView[];
|
||||
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionView | CollectionAdminView>[],
|
||||
): (CollectionView | CollectionAdminView)[] {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return [node.node];
|
||||
}
|
||||
|
||||
const children = getFlatCollectionTree(node.children);
|
||||
return [node.node, ...children];
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./collection-utils";
|
||||
@@ -15,15 +15,15 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
|
||||
import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
|
||||
import {
|
||||
VaultFilterServiceAbstraction,
|
||||
VaultFilterList,
|
||||
VaultFilterSection,
|
||||
VaultFilterType,
|
||||
} 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";
|
||||
CollectionFilter,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -49,7 +49,7 @@ export class VaultFilterComponent
|
||||
protected destroy$: Subject<void>;
|
||||
|
||||
constructor(
|
||||
protected vaultFilterService: VaultFilterService,
|
||||
protected vaultFilterService: VaultFilterServiceAbstraction,
|
||||
protected policyService: PolicyService,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
import { VaultFilterServiceAbstraction } from "@bitwarden/vault";
|
||||
|
||||
import { VaultFilterService as VaultFilterServiceAbstraction } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
|
||||
import { VaultFilterSharedModule } from "../../../../vault/individual-vault/vault-filter/shared/vault-filter-shared.module";
|
||||
|
||||
import { VaultFilterComponent } from "./vault-filter.component";
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import { map, Observable, ReplaySubject, Subject } from "rxjs";
|
||||
|
||||
import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { VaultFilterService as BaseVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/vault-filter.service";
|
||||
import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type";
|
||||
import { VaultFilterService as BaseVaultFilterService, CollectionFilter } from "@bitwarden/vault";
|
||||
|
||||
@Injectable()
|
||||
export class VaultFilterService extends BaseVaultFilterService implements OnDestroy {
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { CollectionAdminService } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
Unassigned,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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";
|
||||
@@ -27,14 +27,10 @@ import {
|
||||
SearchModule,
|
||||
SimpleDialogOptions,
|
||||
} from "@bitwarden/components";
|
||||
import { NewCipherMenuComponent } from "@bitwarden/vault";
|
||||
import { NewCipherMenuComponent, All, RoutedVaultFilterModel } from "@bitwarden/vault";
|
||||
|
||||
import { HeaderModule } from "../../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import {
|
||||
All,
|
||||
RoutedVaultFilterModel,
|
||||
} 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
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
></app-org-vault-header>
|
||||
}
|
||||
|
||||
<div class="tw-flex tw-flex-row">
|
||||
<div class="tw-flex tw-flex-row tw-flex-1">
|
||||
@let hideVaultFilters = hideVaultFilter$ | async;
|
||||
@if (!hideVaultFilters) {
|
||||
<div class="tw-w-1/4 tw-mr-5">
|
||||
@@ -43,7 +43,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
|
||||
<div
|
||||
[class]="(hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4') + ' tw-flex tw-flex-col tw-min-h-0'"
|
||||
>
|
||||
@if (showAddAccessToggle && activeFilter.selectedCollectionNode) {
|
||||
<bit-toggle-group
|
||||
[selected]="addAccessStatus$ | async"
|
||||
@@ -68,6 +70,7 @@
|
||||
|
||||
@if (filter) {
|
||||
<app-vault-items
|
||||
class="tw-flex-1 tw-min-h-0"
|
||||
#vaultItems
|
||||
[ciphers]="ciphers$ | async"
|
||||
[collections]="collections$ | async"
|
||||
|
||||
@@ -27,19 +27,22 @@ import {
|
||||
takeUntil,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
CollectionService,
|
||||
CollectionView,
|
||||
Unassigned,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionAdminService, CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
|
||||
import { NoResults } from "@bitwarden/assets/svg";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionAdminView,
|
||||
Unassigned,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import {
|
||||
getFlatCollectionTree,
|
||||
getNestedCollectionTree,
|
||||
} from "@bitwarden/common/admin-console/utils";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
@@ -81,6 +84,13 @@ import {
|
||||
CollectionAssignmentResult,
|
||||
DecryptionFailureDialogComponent,
|
||||
PasswordRepromptService,
|
||||
VaultFilterServiceAbstraction as VaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
RoutedVaultFilterService,
|
||||
createFilterFunction,
|
||||
All,
|
||||
RoutedVaultFilterModel,
|
||||
VaultFilter,
|
||||
} from "@bitwarden/vault";
|
||||
import {
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
@@ -102,15 +112,6 @@ import {
|
||||
BulkDeleteDialogResult,
|
||||
openBulkDeleteDialog,
|
||||
} from "../../../vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
|
||||
import { VaultFilterService } from "../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
|
||||
import { RoutedVaultFilterBridgeService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service";
|
||||
import { RoutedVaultFilterService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service";
|
||||
import { createFilterFunction } from "../../../vault/individual-vault/vault-filter/shared/models/filter-function";
|
||||
import {
|
||||
All,
|
||||
RoutedVaultFilterModel,
|
||||
} from "../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||
import { VaultFilter } from "../../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
|
||||
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
|
||||
import { GroupApiService, GroupView } from "../core";
|
||||
import { openEntityEventsDialog } from "../manage/entity-events.component";
|
||||
@@ -126,7 +127,6 @@ import {
|
||||
BulkCollectionsDialogResult,
|
||||
} from "./bulk-collections-dialog";
|
||||
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
|
||||
import { getFlatCollectionTree, getNestedCollectionTree } from "./utils";
|
||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
||||
|
||||
@@ -920,14 +920,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
cipher?: CipherView,
|
||||
activeCollectionId?: CollectionId,
|
||||
) {
|
||||
const organization = await firstValueFrom(this.organization$);
|
||||
const disableForm = cipher ? !cipher.edit && !organization.canEditAllCiphers : false;
|
||||
// If the form is disabled, force the mode into `view`
|
||||
const dialogMode = disableForm ? "view" : mode;
|
||||
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
|
||||
mode: dialogMode,
|
||||
mode,
|
||||
formConfig,
|
||||
disableForm,
|
||||
activeCollectionId,
|
||||
isAdminConsoleAction: true,
|
||||
restore: this.restore,
|
||||
@@ -1318,7 +1313,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
selectedCollection?.node.id === c.id
|
||||
) {
|
||||
void this.router.navigate([], {
|
||||
queryParams: { collectionId: selectedCollection.parent.node.id ?? null },
|
||||
queryParams: { collectionId: selectedCollection.parent?.node.id ?? null },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
export interface AddEditGroupDetail {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
import { GroupDetailsResponse } from "../services/group/responses/group.response";
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
OrganizationUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
export class OrganizationUserAdminView {
|
||||
id: string;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
OrganizationUserUserDetailsResponse,
|
||||
CollectionAccessSelectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
export class OrganizationUserView {
|
||||
id: string;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { BannerModule, IconModule } from "@bitwarden/components";
|
||||
import { BannerModule, SvgModule } from "@bitwarden/components";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
@@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module";
|
||||
RouterModule,
|
||||
JslibModule,
|
||||
WebLayoutModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
OrgSwitcherComponent,
|
||||
BannerModule,
|
||||
TaxIdWarningComponent,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
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";
|
||||
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
} from "rxjs";
|
||||
import { debounceTime, first } from "rxjs/operators";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionData,
|
||||
Collection,
|
||||
CollectionView,
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
Collection,
|
||||
CollectionData,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -14,7 +17,8 @@ export type UserConfirmDialogData = {
|
||||
name: string;
|
||||
userId: string;
|
||||
publicKey: Uint8Array;
|
||||
confirmUser: (publicKey: Uint8Array) => Promise<void>;
|
||||
// @TODO remove this when doing feature flag cleanup for members component refactor.
|
||||
confirmUser?: (publicKey: Uint8Array) => Promise<void>;
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -42,6 +46,7 @@ export class UserConfirmComponent implements OnInit {
|
||||
private keyService: KeyService,
|
||||
private logService: LogService,
|
||||
private organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.name = data.name;
|
||||
this.userId = data.userId;
|
||||
@@ -64,16 +69,21 @@ export class UserConfirmComponent implements OnInit {
|
||||
|
||||
submit = async () => {
|
||||
if (this.loading) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.formGroup.value.dontAskAgain) {
|
||||
await this.organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
|
||||
}
|
||||
|
||||
await this.data.confirmUser(this.publicKey);
|
||||
const membersComponentRefactorEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.MembersComponentRefactor),
|
||||
);
|
||||
if (!membersComponentRefactorEnabled) {
|
||||
await this.data.confirmUser(this.publicKey);
|
||||
}
|
||||
|
||||
this.dialogRef.close();
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<UserConfirmDialogData>) {
|
||||
|
||||
@@ -36,6 +36,7 @@ type BulkConfirmDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-confirm-dialog.component.html",
|
||||
selector: "member-bulk-comfirm-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
|
||||
@@ -20,6 +20,7 @@ type BulkDeleteDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-delete-dialog.component.html",
|
||||
selector: "member-bulk-delete-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkDeleteDialogComponent {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type BulkEnableSecretsManagerDialogData = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: `bulk-enable-sm-dialog.component.html`,
|
||||
selector: "member-bulk-enable-sm-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkEnableSecretsManagerDialogComponent implements OnInit {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<bit-simple-dialog hideIcon>
|
||||
<div bitDialogContent>
|
||||
<div class="tw-flex tw-justify-center">
|
||||
<div class="tw-mt-1 tw-w-[273px]">
|
||||
<bit-progress
|
||||
[showText]="false"
|
||||
size="default"
|
||||
bgColor="primary"
|
||||
[barWidth]="progressPercentage()"
|
||||
></bit-progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2 tw-mt-6">
|
||||
<h3 class="tw-font-semibold">
|
||||
{{ "bulkReinviteProgressTitle" | i18n: progressCount() : allCount }}
|
||||
</h3>
|
||||
<span class="tw-text-sm">
|
||||
{{ "bulkReinviteProgressSubtitle" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</bit-simple-dialog>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
Inject,
|
||||
Signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { DIALOG_DATA, DialogService } from "@bitwarden/components";
|
||||
|
||||
export interface BulkProgressDialogParams {
|
||||
progress: Signal<number>;
|
||||
allCount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "bulk-progress-dialog.component.html",
|
||||
selector: "member-bulk-progress-dialog",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkProgressDialogComponent {
|
||||
protected allCount: string;
|
||||
protected readonly progressCount: Signal<string>;
|
||||
protected readonly progressPercentage: Signal<number>;
|
||||
private readonly progressEffect = effect(() => {
|
||||
if (this.progressPercentage() >= 100) {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) data: BulkProgressDialogParams,
|
||||
) {
|
||||
this.progressCount = computed(() => data.progress().toLocaleString());
|
||||
this.allCount = data.allCount.toLocaleString();
|
||||
this.progressPercentage = computed(() => (data.progress() / data.allCount) * 100);
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<BulkProgressDialogParams>) {
|
||||
return dialogService.open(BulkProgressDialogComponent, config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<bit-dialog dialogSize="large">
|
||||
@let failCount = dataSource().data.length;
|
||||
<div bitDialogTitle>
|
||||
@if (failCount > 1) {
|
||||
{{ "bulkReinviteFailuresTitle" | i18n: failCount }}
|
||||
} @else {
|
||||
{{ "bulkReinviteFailureTitle" | i18n }}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div bitDialogContent>
|
||||
{{ "bulkReinviteFailureDescription" | i18n: failCount : totalCount }}
|
||||
|
||||
<a bitLink href="https://bitwarden.com/contact/" target="_blank" rel="noopener noreferrer">
|
||||
{{ "contactSupportShort" | i18n | lowercase }}
|
||||
<bit-icon name="bwi-external-link"></bit-icon>
|
||||
</a>
|
||||
|
||||
<div class="tw-max-h-[304px] tw-overflow-auto tw-mt-4">
|
||||
<bit-table [dataSource]="dataSource()">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
|
||||
<ng-template body let-rows$>
|
||||
@let rows = $any(rows$ | async);
|
||||
@for (u of rows; track u.id) {
|
||||
<tr bitRow class="tw-h-16">
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="u | userName"
|
||||
[id]="u.userId"
|
||||
[color]="u.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<button type="button" bitLink>
|
||||
{{ u.name ?? u.email }}
|
||||
</button>
|
||||
</div>
|
||||
@if (u.name) {
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ u.email }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" type="button" (click)="resendInvitations()">
|
||||
{{ "bulkResendInvitations" | i18n }}
|
||||
</button>
|
||||
|
||||
<button bitButton buttonType="secondary" type="button" (click)="cancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { ChangeDetectionStrategy, Component, Inject, signal, WritableSignal } from "@angular/core";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { MembersTableDataSource } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
import { OrganizationUserView } from "../../../core";
|
||||
import {
|
||||
BulkActionResult,
|
||||
MemberActionsService,
|
||||
} from "../../services/member-actions/member-actions.service";
|
||||
|
||||
export interface BulkReinviteFailureDialogParams {
|
||||
result: BulkActionResult;
|
||||
users: OrganizationUserView[];
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "bulk-reinvite-failure-dialog.component.html",
|
||||
selector: "member-bulk-reinvite-failure-dialog",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkReinviteFailureDialogComponent {
|
||||
private organization: Organization;
|
||||
protected totalCount: string;
|
||||
protected readonly dataSource: WritableSignal<MembersTableDataSource>;
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
private memberActionsService: MemberActionsService,
|
||||
@Inject(DIALOG_DATA) data: BulkReinviteFailureDialogParams,
|
||||
environmentService: EnvironmentService,
|
||||
) {
|
||||
this.organization = data.organization;
|
||||
this.totalCount = (data.users.length ?? 0).toLocaleString();
|
||||
this.dataSource = signal(new MembersTableDataSource(environmentService));
|
||||
this.dataSource().data = data.result.failed.map((failedUser) => {
|
||||
const user = data.users.find((u) => u.id === failedUser.id);
|
||||
if (user == null) {
|
||||
throw new Error("Member not found");
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async resendInvitations() {
|
||||
await this.memberActionsService.bulkReinvite(this.organization, this.dataSource().data);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<BulkReinviteFailureDialogParams>) {
|
||||
return dialogService.open(BulkReinviteFailureDialogComponent, config);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type BulkRemoveDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-remove-dialog.component.html",
|
||||
selector: "member-bulk-remove-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<bit-callout
|
||||
type="danger"
|
||||
*ngIf="nonCompliantMembers"
|
||||
*ngIf="nonCompliantMembers && !isRevoking"
|
||||
title="{{ 'nonCompliantMembersTitle' | i18n }}"
|
||||
>
|
||||
{{ "nonCompliantMembersError" | i18n }}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DIALOG_DATA, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { BulkUserDetails } from "./bulk-status.component";
|
||||
@@ -18,7 +29,7 @@ type BulkRestoreDialogParams = {
|
||||
// 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",
|
||||
selector: "member-bulk-restore-revoke",
|
||||
templateUrl: "bulk-restore-revoke.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
@@ -34,10 +45,15 @@ export class BulkRestoreRevokeComponent {
|
||||
error: string;
|
||||
showNoMasterPasswordWarning = false;
|
||||
nonCompliantMembers: boolean = false;
|
||||
organization$: Observable<Organization>;
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private accountService: AccountService,
|
||||
private organizationService: OrganizationService,
|
||||
private configService: ConfigService,
|
||||
@Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams,
|
||||
) {
|
||||
this.isRevoking = data.isRevoking;
|
||||
@@ -46,6 +62,18 @@ export class BulkRestoreRevokeComponent {
|
||||
this.showNoMasterPasswordWarning = this.users.some(
|
||||
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
|
||||
);
|
||||
|
||||
this.organization$ = accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => organizationService.organizations$(userId)),
|
||||
getById(this.organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
get bulkTitle() {
|
||||
@@ -60,12 +88,9 @@ export class BulkRestoreRevokeComponent {
|
||||
const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage";
|
||||
|
||||
response.data.forEach(async (entry) => {
|
||||
const error =
|
||||
entry.error !== ""
|
||||
? this.i18nService.t("cannotRestoreAccessError")
|
||||
: this.i18nService.t(bulkMessage);
|
||||
this.statuses.set(entry.id, error);
|
||||
if (entry.error !== "") {
|
||||
const status = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage);
|
||||
this.statuses.set(entry.id, status);
|
||||
if (entry.error !== "" && !this.isRevoking) {
|
||||
this.nonCompliantMembers = true;
|
||||
}
|
||||
});
|
||||
@@ -83,9 +108,22 @@ export class BulkRestoreRevokeComponent {
|
||||
userIds,
|
||||
);
|
||||
} else {
|
||||
return await this.organizationUserApiService.restoreManyOrganizationUsers(
|
||||
this.organizationId,
|
||||
userIds,
|
||||
return await firstValueFrom(
|
||||
combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore),
|
||||
this.organization$,
|
||||
]).pipe(
|
||||
switchMap(([enabled, organization]) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.bulkRestoreUsers(organization, userIds);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreManyOrganizationUsers(
|
||||
this.organizationId,
|
||||
userIds,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type BulkStatusDialogData = {
|
||||
// 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",
|
||||
selector: "member-bulk-status",
|
||||
templateUrl: "bulk-status.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
|
||||
@@ -15,27 +15,29 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
OrganizationUserApiService,
|
||||
CollectionView,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
CollectionAdminView,
|
||||
CollectionView,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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 { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
@@ -195,14 +197,19 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
) {
|
||||
this.organization$ = accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
organizationService
|
||||
.organizations$(account?.id)
|
||||
.pipe(getOrganizationById(this.params.organizationId))
|
||||
.pipe(shareReplay({ refCount: true, bufferSize: 1 })),
|
||||
),
|
||||
getUserId,
|
||||
switchMap((userId) => organizationService.organizations$(userId)),
|
||||
getById(this.params.organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
let userDetails$;
|
||||
@@ -631,9 +638,26 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.organizationUserApiService.restoreOrganizationUser(
|
||||
this.params.organizationId,
|
||||
this.params.organizationUserId,
|
||||
await firstValueFrom(
|
||||
combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore),
|
||||
this.organization$,
|
||||
this.editParams$,
|
||||
]).pipe(
|
||||
switchMap(([enabled, organization, params]) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.restoreUser(
|
||||
organization,
|
||||
params.organizationUserId,
|
||||
);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreOrganizationUser(
|
||||
params.organizationId,
|
||||
params.organizationUserId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
@let organization = this.organization();
|
||||
@if (organization) {
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
|
||||
>
|
||||
</app-organization-free-trial-warning>
|
||||
<app-header>
|
||||
<bit-search
|
||||
class="tw-grow"
|
||||
[formControl]="searchControl"
|
||||
[placeholder]="'searchMembers' | i18n"
|
||||
></bit-search>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="invite(organization)"
|
||||
[disabled]="!firstLoaded"
|
||||
*ngIf="showUserManagementControls()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "inviteMember" | i18n }}
|
||||
</button>
|
||||
</app-header>
|
||||
|
||||
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
|
||||
<bit-toggle-group
|
||||
[selected]="status"
|
||||
(selectedChange)="statusToggle.next($event)"
|
||||
[attr.aria-label]="'memberStatusFilter' | i18n"
|
||||
*ngIf="showUserManagementControls()"
|
||||
>
|
||||
<bit-toggle [value]="null">
|
||||
{{ "all" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
|
||||
allCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Invited">
|
||||
{{ "invited" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
|
||||
invitedCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Accepted">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
|
||||
acceptedUserCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Revoked">
|
||||
{{ "revoked" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
|
||||
revokedCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
</div>
|
||||
<ng-container *ngIf="!firstLoaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="firstLoaded">
|
||||
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
|
||||
<ng-container *ngIf="dataSource.filteredData.length">
|
||||
<bit-callout
|
||||
type="info"
|
||||
title="{{ 'confirmUsers' | i18n }}"
|
||||
icon="bwi-check-circle"
|
||||
*ngIf="showConfirmUsers"
|
||||
>
|
||||
{{ "usersNeedConfirmed" | i18n }}
|
||||
</bit-callout>
|
||||
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
|
||||
from overflowing the <main> element. -->
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
class="tw-mr-1"
|
||||
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
|
||||
id="selectAll"
|
||||
/>
|
||||
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
|
||||
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
|
||||
<th bitCell>{{ "policies" | i18n }}</th>
|
||||
<th bitCell>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
*ngIf="showUserManagementControls()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<ng-container *ngIf="canUseSecretsManager()">
|
||||
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
|
||||
{{ "activateSecretsManager" | i18n }}
|
||||
</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkReinvite(organization)"
|
||||
*ngIf="showBulkReinviteUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkConfirm(organization)"
|
||||
*ngIf="showBulkConfirmUsers"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
{{ "confirmSelected" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRestore(organization)"
|
||||
*ngIf="showBulkRestoreUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRevoke(organization)"
|
||||
*ngIf="showBulkRevokeUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRemove(organization)"
|
||||
*ngIf="showBulkRemoveUsers"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkDelete(organization)"
|
||||
*ngIf="showBulkDeleteUsers"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr
|
||||
bitRow
|
||||
*cdkVirtualFor="let u of rows$"
|
||||
alignContent="middle"
|
||||
[ngClass]="rowHeightClass"
|
||||
>
|
||||
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
|
||||
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
|
||||
</td>
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
|
||||
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="u | userName"
|
||||
[id]="u.userId"
|
||||
[color]="u.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<button type="button" bitLink>
|
||||
{{ u.name ?? u.email }}
|
||||
</button>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="warning"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
|
||||
{{ u.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyUserInfo>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="u | userName"
|
||||
[id]="u.userId"
|
||||
[color]="u.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<span>{{ u.name ?? u.email }}</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="warning"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
|
||||
{{ u.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
|
||||
<td
|
||||
bitCell
|
||||
(click)="
|
||||
edit(
|
||||
u,
|
||||
organization,
|
||||
organization.useGroups ? memberTab.Groups : memberTab.Collections
|
||||
)
|
||||
"
|
||||
class="tw-cursor-pointer"
|
||||
>
|
||||
<bit-badge-list
|
||||
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
|
||||
[maxItems]="3"
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyGroupsCell>
|
||||
<td bitCell>
|
||||
<bit-badge-list
|
||||
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
|
||||
[maxItems]="3"
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
|
||||
<td
|
||||
bitCell
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
class="tw-cursor-pointer tw-text-sm tw-text-muted"
|
||||
>
|
||||
{{ u.type | userType }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyRoleCell>
|
||||
<td bitCell class="tw-text-sm tw-text-muted">
|
||||
{{ u.type | userType }}
|
||||
</td>
|
||||
</ng-template>
|
||||
|
||||
<td bitCell class="tw-text-muted">
|
||||
<ng-container *ngIf="u.twoFactorEnabled">
|
||||
<i
|
||||
class="bwi bwi-lock"
|
||||
title="{{ 'userUsingTwoStep' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
||||
</ng-container>
|
||||
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
|
||||
<ng-container
|
||||
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
title="{{ 'enrolledAccountRecovery' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
|
||||
</ng-container>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<div class="tw-w-[32px]"></div>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<ng-container *ngIf="showUserManagementControls()">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="reinvite(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-envelope"></i>
|
||||
{{ "resendInvitation" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="confirm(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<bit-menu-divider
|
||||
*ngIf="
|
||||
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
|
||||
"
|
||||
></bit-menu-divider>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Groups)"
|
||||
*ngIf="organization.useGroups"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Collections)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
|
||||
{{ "collections" | i18n }}
|
||||
</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="openEventsDialog(u, organization)"
|
||||
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Account recovery is available to all users with appropriate permissions -->
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="resetPassword(u, organization)"
|
||||
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
|
||||
</button>
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls()">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="restore(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="revoke(u, organization)"
|
||||
*ngIf="u.status !== userStatusType.Revoked"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="remove(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="deleteUser(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { Component, computed, Signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
concatMap,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
PolicyType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
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 { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
import { BaseMembersComponent } from "../../common/base-members.component";
|
||||
import {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
PeopleTableDataSource,
|
||||
} from "../../common/people-table-data-source";
|
||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
|
||||
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
|
||||
import {
|
||||
MemberDialogManagerService,
|
||||
MemberExportService,
|
||||
OrganizationMembersService,
|
||||
} from "./services";
|
||||
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
|
||||
import {
|
||||
MemberActionsService,
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||
protected statusType = OrganizationUserStatusType;
|
||||
}
|
||||
|
||||
// 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: "deprecated_members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
|
||||
userType = OrganizationUserType;
|
||||
userStatusType = OrganizationUserStatusType;
|
||||
memberTab = MemberDialogTab;
|
||||
protected dataSource: MembersTableDataSource;
|
||||
|
||||
readonly organization: Signal<Organization | undefined>;
|
||||
status: OrganizationUserStatusType | undefined;
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||
|
||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||
() => this.organization()?.useSecretsManager ?? false,
|
||||
);
|
||||
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
||||
() => this.organization()?.canManageUsers ?? false,
|
||||
);
|
||||
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 66;
|
||||
protected rowHeightClass = `tw-h-[66px]`;
|
||||
|
||||
constructor(
|
||||
apiService: ApiService,
|
||||
i18nService: I18nService,
|
||||
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
keyService: KeyService,
|
||||
validationService: ValidationService,
|
||||
logService: LogService,
|
||||
userNamePipe: UserNamePipe,
|
||||
dialogService: DialogService,
|
||||
toastService: ToastService,
|
||||
private route: ActivatedRoute,
|
||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private memberActionsService: MemberActionsService,
|
||||
private memberDialogManager: MemberDialogManagerService,
|
||||
protected billingConstraint: BillingConstraintService,
|
||||
protected memberService: OrganizationMembersService,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private policyService: PolicyService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
private memberExportService: MemberExportService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.environmentService);
|
||||
|
||||
const organization$ = this.route.params.pipe(
|
||||
concatMap((params) =>
|
||||
this.userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
),
|
||||
filter((organization): organization is Organization => organization != null),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.organization = toSignal(organization$);
|
||||
|
||||
const policies$ = combineLatest([this.userId$, organization$]).pipe(
|
||||
switchMap(([userId, organization]) =>
|
||||
organization.isProviderUser
|
||||
? from(this.policyApiService.getPolicies(organization.id)).pipe(
|
||||
map((response) => Policy.fromListResponse(response)),
|
||||
)
|
||||
: this.policyService.policies$(userId),
|
||||
),
|
||||
);
|
||||
|
||||
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
|
||||
map(
|
||||
([organization, policies]) =>
|
||||
policies
|
||||
.filter((policy) => policy.type === PolicyType.ResetPassword)
|
||||
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
combineLatest([this.route.queryParams, organization$])
|
||||
.pipe(
|
||||
concatMap(async ([qParams, organization]) => {
|
||||
await this.load(organization!);
|
||||
|
||||
this.searchControl.setValue(qParams.search);
|
||||
|
||||
if (qParams.viewEvents != null) {
|
||||
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
||||
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
|
||||
this.openEventsDialog(user[0], organization!);
|
||||
}
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
organization$
|
||||
.pipe(
|
||||
switchMap((organization) =>
|
||||
merge(
|
||||
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
|
||||
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.billingMetadata$ = organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
// Stripe is slow, so kick this off in the background but without blocking page load.
|
||||
// Anyone who needs it will still await the first emission.
|
||||
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
|
||||
}
|
||||
|
||||
override async load(organization: Organization) {
|
||||
await super.load(organization);
|
||||
}
|
||||
|
||||
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
|
||||
return await this.memberService.loadUsers(organization);
|
||||
}
|
||||
|
||||
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.removeUser(organization, id);
|
||||
}
|
||||
|
||||
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.revokeUser(organization, id);
|
||||
}
|
||||
|
||||
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.restoreUser(organization, id);
|
||||
}
|
||||
|
||||
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.reinviteUser(organization, id);
|
||||
}
|
||||
|
||||
async confirmUser(
|
||||
user: OrganizationUserView,
|
||||
publicKey: Uint8Array,
|
||||
organization: Organization,
|
||||
): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.confirmUser(user, publicKey, organization);
|
||||
}
|
||||
|
||||
async revoke(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.revokeUserConfirmationDialog(user);
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.actionPromise = this.revokeUser(user.id, organization);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
|
||||
});
|
||||
await this.load(organization);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
async restore(user: OrganizationUserView, organization: Organization) {
|
||||
this.actionPromise = this.restoreUser(user.id, organization);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
|
||||
});
|
||||
await this.load(organization);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
orgUser: OrganizationUserView,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
return this.memberActionsService.allowResetPassword(
|
||||
orgUser,
|
||||
organization,
|
||||
orgResetPasswordPolicyEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
showEnrolledStatus(
|
||||
orgUser: OrganizationUserUserDetailsResponse,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
organization.useResetPassword &&
|
||||
orgUser.resetPasswordEnrolled &&
|
||||
orgResetPasswordPolicyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInviteDialog(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
|
||||
|
||||
const result = await this.memberDialogManager.openInviteDialog(
|
||||
organization,
|
||||
billingMetadata,
|
||||
allUserEmails,
|
||||
);
|
||||
|
||||
if (result === MemberDialogResult.Saved) {
|
||||
await this.load(organization);
|
||||
}
|
||||
}
|
||||
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
|
||||
await this.handleInviteDialog(organization);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
}
|
||||
}
|
||||
|
||||
async edit(
|
||||
user: OrganizationUserView,
|
||||
organization: Organization,
|
||||
initialTab: MemberDialogTab = MemberDialogTab.Role,
|
||||
) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
|
||||
const result = await this.memberDialogManager.openEditDialog(
|
||||
user,
|
||||
organization,
|
||||
billingMetadata,
|
||||
initialTab,
|
||||
);
|
||||
|
||||
switch (result) {
|
||||
case MemberDialogResult.Deleted:
|
||||
this.dataSource.removeUser(user);
|
||||
break;
|
||||
case MemberDialogResult.Saved:
|
||||
case MemberDialogResult.Revoked:
|
||||
case MemberDialogResult.Restored:
|
||||
await this.load(organization);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkRemove(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkDelete(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkRevoke(organization: Organization) {
|
||||
await this.bulkRevokeOrRestore(true, organization);
|
||||
}
|
||||
|
||||
async bulkRestore(organization: Organization) {
|
||||
await this.bulkRevokeOrRestore(false, organization);
|
||||
}
|
||||
|
||||
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let users: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
|
||||
|
||||
// Capture the original count BEFORE enforcing the limit
|
||||
const originalInvitedCount = allInvitedUsers.length;
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
let filteredUsers: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource.limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
} else {
|
||||
filteredUsers = allInvitedUsers;
|
||||
}
|
||||
|
||||
if (filteredUsers.length <= 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("noSelectedUsersApplicable"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message:
|
||||
invitedCount === 1
|
||||
? this.i18nService.t("reinviteSuccessToast")
|
||||
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
async bulkConfirm(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkEnableSM(organization: Organization) {
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
|
||||
|
||||
this.dataSource.uncheckAllUsers();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
openEventsDialog(user: OrganizationUserView, organization: Organization) {
|
||||
this.memberDialogManager.openEventsDialog(user, organization);
|
||||
}
|
||||
|
||||
async resetPassword(user: OrganizationUserView, organization: Organization) {
|
||||
if (!user || !user.email || !user.id) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("orgUserDetailsNotFound"),
|
||||
});
|
||||
this.logService.error("Org user details not found when attempting account recovery");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
|
||||
if (result === AccountRecoveryDialogResultType.Ok) {
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
|
||||
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
|
||||
}
|
||||
|
||||
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
|
||||
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
|
||||
}
|
||||
|
||||
async deleteUser(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
|
||||
user,
|
||||
organization,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
|
||||
});
|
||||
this.dataSource.removeUser(user);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
get showBulkRestoreUsers(): boolean {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.status == this.userStatusType.Revoked);
|
||||
}
|
||||
|
||||
get showBulkRevokeUsers(): boolean {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.status != this.userStatusType.Revoked);
|
||||
}
|
||||
|
||||
get showBulkRemoveUsers(): boolean {
|
||||
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
|
||||
}
|
||||
|
||||
get showBulkDeleteUsers(): boolean {
|
||||
const validStatuses = [
|
||||
this.userStatusType.Accepted,
|
||||
this.userStatusType.Confirmed,
|
||||
this.userStatusType.Revoked,
|
||||
];
|
||||
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
|
||||
}
|
||||
|
||||
get selectedInvitedCount(): number {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.filter((member) => member.status === this.userStatusType.Invited).length;
|
||||
}
|
||||
|
||||
get isSingleInvite(): boolean {
|
||||
return this.selectedInvitedCount === 1;
|
||||
}
|
||||
|
||||
exportMembers = () => {
|
||||
const result = this.memberExportService.getMemberExport(this.dataSource.data);
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("dataExportSuccess"),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.error != null) {
|
||||
this.validationService.showError(result.error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component";
|
||||
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
|
||||
|
||||
import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard";
|
||||
import { MembersComponent } from "./members.component";
|
||||
import { MembersComponent } from "./deprecated_members.component";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
component: MembersComponent,
|
||||
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
|
||||
data: {
|
||||
titleId: "members",
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: MembersComponent,
|
||||
flaggedComponent: vNextMembersComponent,
|
||||
featureFlag: FeatureFlag.MembersComponentRefactor,
|
||||
routeOptions: {
|
||||
path: "",
|
||||
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
|
||||
data: {
|
||||
titleId: "members",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "sponsored-families",
|
||||
component: FreeBitwardenFamiliesComponent,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
@let organization = this.organization();
|
||||
@if (organization) {
|
||||
@let dataSource = this.dataSource();
|
||||
@let bulkActions = bulkMenuOptions$ | async;
|
||||
@let showConfirmBanner = showConfirmBanner$ | async;
|
||||
@let isProcessing = this.isProcessing();
|
||||
@let isSingleInvite = isSingleInvite$ | async;
|
||||
|
||||
@if (organization && dataSource) {
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
|
||||
@@ -12,183 +18,198 @@
|
||||
[placeholder]="'searchMembers' | i18n"
|
||||
></bit-search>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="invite(organization)"
|
||||
[disabled]="!firstLoaded"
|
||||
*ngIf="showUserManagementControls()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "inviteMember" | i18n }}
|
||||
</button>
|
||||
@if (showUserManagementControls()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="invite(organization)"
|
||||
[disabled]="!firstLoaded()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw tw-me-2" aria-hidden="true"></i>
|
||||
{{ "inviteMember" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</app-header>
|
||||
|
||||
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
|
||||
<bit-toggle-group
|
||||
[selected]="status"
|
||||
(selectedChange)="statusToggle.next($event)"
|
||||
[attr.aria-label]="'memberStatusFilter' | i18n"
|
||||
*ngIf="showUserManagementControls()"
|
||||
>
|
||||
<bit-toggle [value]="null">
|
||||
{{ "all" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
|
||||
allCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
@if (showUserManagementControls()) {
|
||||
<bit-toggle-group
|
||||
[selected]="statusToggle | async"
|
||||
(selectedChange)="statusToggle.next($event)"
|
||||
[attr.aria-label]="'memberStatusFilter' | i18n"
|
||||
>
|
||||
<bit-toggle [value]="undefined">
|
||||
{{ "all" | i18n }}
|
||||
@if (dataSource.activeUserCount; as allCount) {
|
||||
<span bitBadge variant="info">{{ allCount }}</span>
|
||||
}
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Invited">
|
||||
{{ "invited" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
|
||||
invitedCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
<bit-toggle [value]="userStatusType.Invited">
|
||||
{{ "invited" | i18n }}
|
||||
@if (dataSource.invitedUserCount; as invitedCount) {
|
||||
<span bitBadge variant="info">{{ invitedCount }}</span>
|
||||
}
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Accepted">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
|
||||
acceptedUserCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
<bit-toggle [value]="userStatusType.Accepted">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
@if (dataSource.acceptedUserCount; as acceptedUserCount) {
|
||||
<span bitBadge variant="info">{{ acceptedUserCount }}</span>
|
||||
}
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Revoked">
|
||||
{{ "revoked" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
|
||||
revokedCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
<bit-toggle [value]="userStatusType.Revoked">
|
||||
{{ "revoked" | i18n }}
|
||||
@if (dataSource.revokedUserCount; as revokedCount) {
|
||||
<span bitBadge variant="info">{{ revokedCount }}</span>
|
||||
}
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
}
|
||||
</div>
|
||||
<ng-container *ngIf="!firstLoaded">
|
||||
@if (!firstLoaded() || !organization || !dataSource) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="firstLoaded">
|
||||
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
|
||||
<ng-container *ngIf="dataSource.filteredData.length">
|
||||
<bit-callout
|
||||
type="info"
|
||||
title="{{ 'confirmUsers' | i18n }}"
|
||||
icon="bwi-check-circle"
|
||||
*ngIf="showConfirmUsers"
|
||||
>
|
||||
{{ "usersNeedConfirmed" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
@if (!dataSource.filteredData?.length) {
|
||||
<p>{{ "noMembersInList" | i18n }}</p>
|
||||
}
|
||||
@if (dataSource.filteredData?.length) {
|
||||
@if (showConfirmBanner) {
|
||||
<bit-callout type="info" title="{{ 'confirmUsers' | i18n }}" icon="bwi-check-circle">
|
||||
{{ "usersNeedConfirmed" | i18n }}
|
||||
</bit-callout>
|
||||
}
|
||||
|
||||
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
|
||||
from overflowing the <main> element. -->
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
class="tw-mr-1"
|
||||
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
|
||||
id="selectAll"
|
||||
/>
|
||||
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
@if (showUserManagementControls()) {
|
||||
<th bitCell class="tw-w-20">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
class="tw-mr-1"
|
||||
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
|
||||
id="selectAll"
|
||||
/>
|
||||
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
}
|
||||
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
|
||||
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
|
||||
<th bitCell>{{ "policies" | i18n }}</th>
|
||||
<th bitCell>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
*ngIf="showUserManagementControls()"
|
||||
></button>
|
||||
</div>
|
||||
<th bitCell class="tw-w-10">
|
||||
@if (showUserManagementControls()) {
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
*ngIf="showUserManagementControls()"
|
||||
></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<ng-container *ngIf="canUseSecretsManager()">
|
||||
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
|
||||
@if (canUseSecretsManager()) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkEnableSM(organization)"
|
||||
>
|
||||
{{ "activateSecretsManager" | i18n }}
|
||||
</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkReinvite(organization)"
|
||||
*ngIf="showBulkReinviteUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkConfirm(organization)"
|
||||
*ngIf="showBulkConfirmUsers"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
{{ "confirmSelected" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRestore(organization)"
|
||||
*ngIf="showBulkRestoreUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRevoke(organization)"
|
||||
*ngIf="showBulkRevokeUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkRemove(organization)"
|
||||
*ngIf="showBulkRemoveUsers"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkDelete(organization)"
|
||||
*ngIf="showBulkDeleteUsers"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkReinviteUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkReinvite(organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkConfirmUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkConfirm(organization)"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
{{ "confirmSelected" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkRestoreUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkRevokeOrRestore(false, organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkRevokeUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkRevokeOrRestore(true, organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkRemoveUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkRemove(organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkDeleteUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkDelete(organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -200,10 +221,10 @@
|
||||
alignContent="middle"
|
||||
[ngClass]="rowHeightClass"
|
||||
>
|
||||
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
|
||||
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
|
||||
</td>
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
|
||||
@if (showUserManagementControls()) {
|
||||
<td bitCell (click)="dataSource.checkUser(u)">
|
||||
<input type="checkbox" bitCheckbox [(ngModel)]="u.checked" />
|
||||
</td>
|
||||
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
@@ -218,39 +239,31 @@
|
||||
<button type="button" bitLink>
|
||||
{{ u.name ?? u.email }}
|
||||
</button>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="warning"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
|
||||
{{ u.email }}
|
||||
@if (u.status === userStatusType.Invited) {
|
||||
<span bitBadge class="tw-text-xs" variant="secondary">
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (u.status === userStatusType.Accepted) {
|
||||
<span bitBadge class="tw-text-xs" variant="warning">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (u.status === userStatusType.Revoked) {
|
||||
<span bitBadge class="tw-text-xs" variant="secondary">
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (u.name) {
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ u.email }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyUserInfo>
|
||||
} @else {
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
@@ -263,40 +276,33 @@
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<span>{{ u.name ?? u.email }}</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="warning"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
<span
|
||||
bitBadge
|
||||
class="tw-text-xs"
|
||||
variant="secondary"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
|
||||
{{ u.email }}
|
||||
@if (u.status === userStatusType.Invited) {
|
||||
<span bitBadge class="tw-text-xs" variant="secondary">
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (u.status === userStatusType.Accepted) {
|
||||
<span bitBadge class="tw-text-xs" variant="warning">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (u.status === userStatusType.Revoked) {
|
||||
<span bitBadge class="tw-text-xs" variant="secondary">
|
||||
{{ "revoked" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (u.name) {
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ u.email }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
|
||||
@if (showUserManagementControls()) {
|
||||
<td
|
||||
bitCell
|
||||
(click)="
|
||||
@@ -314,8 +320,7 @@
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyGroupsCell>
|
||||
} @else {
|
||||
<td bitCell>
|
||||
<bit-badge-list
|
||||
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
|
||||
@@ -323,9 +328,9 @@
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
|
||||
@if (showUserManagementControls()) {
|
||||
<td
|
||||
bitCell
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
@@ -333,33 +338,30 @@
|
||||
>
|
||||
{{ u.type | userType }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyRoleCell>
|
||||
} @else {
|
||||
<td bitCell class="tw-text-sm tw-text-muted">
|
||||
{{ u.type | userType }}
|
||||
</td>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<td bitCell class="tw-text-muted">
|
||||
<ng-container *ngIf="u.twoFactorEnabled">
|
||||
@if (u.twoFactorEnabled) {
|
||||
<i
|
||||
class="bwi bwi-lock"
|
||||
title="{{ 'userUsingTwoStep' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
|
||||
<ng-container
|
||||
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
|
||||
>
|
||||
@if (showEnrolledStatus(u, organization, resetPasswordPolicyEnabled)) {
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
title="{{ 'enrolledAccountRecovery' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
</td>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
@@ -374,122 +376,131 @@
|
||||
</div>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<ng-container *ngIf="showUserManagementControls()">
|
||||
@if (showUserManagementControls()) {
|
||||
@if (u.status === userStatusType.Invited) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : reinvite(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-envelope"></i>
|
||||
{{ "resendInvitation" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (u.status === userStatusType.Accepted) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : confirm(u, organization)"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
|
||||
) {
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="reinvite(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Invited"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-envelope"></i>
|
||||
{{ "resendInvitation" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="confirm(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Accepted"
|
||||
>
|
||||
<span class="tw-text-success">
|
||||
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<bit-menu-divider
|
||||
*ngIf="
|
||||
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
|
||||
"
|
||||
></bit-menu-divider>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Role)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
|
||||
</button>
|
||||
@if (organization.useGroups) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Groups)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Groups)"
|
||||
*ngIf="organization.useGroups"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Collections)"
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Collections)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
|
||||
{{ "collections" | i18n }}
|
||||
</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="openEventsDialog(u, organization)"
|
||||
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
@if (organization.useEvents && u.status === userStatusType.Confirmed) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : openEventsDialog(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-file-text"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Account recovery is available to all users with appropriate permissions -->
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="resetPassword(u, organization)"
|
||||
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
|
||||
</button>
|
||||
@if (allowResetPassword(u, organization, resetPasswordPolicyEnabled)) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : resetPassword(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls()">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="restore(u, organization)"
|
||||
*ngIf="u.status === userStatusType.Revoked"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="revoke(u, organization)"
|
||||
*ngIf="u.status !== userStatusType.Revoked"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="remove(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="deleteUser(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
@if (showUserManagementControls()) {
|
||||
@if (u.status === userStatusType.Revoked) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : restore(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
|
||||
{{ "restoreAccess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (u.status !== userStatusType.Revoked) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : revoke(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (!u.managedByOrganization) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : remove(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : deleteUser(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} 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 { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
|
||||
import { MemberDialogResult } from "./components/member-dialog";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
import {
|
||||
MemberDialogManagerService,
|
||||
MemberExportService,
|
||||
OrganizationMembersService,
|
||||
} from "./services";
|
||||
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
|
||||
import {
|
||||
MemberActionsService,
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
describe("vNextMembersComponent", () => {
|
||||
let component: vNextMembersComponent;
|
||||
let fixture: ComponentFixture<vNextMembersComponent>;
|
||||
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockOrganizationManagementPreferencesService: MockProxy<OrganizationManagementPreferencesService>;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
let mockValidationService: MockProxy<ValidationService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockUserNamePipe: MockProxy<UserNamePipe>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockActivatedRoute: ActivatedRoute;
|
||||
let mockDeleteManagedMemberWarningService: MockProxy<DeleteManagedMemberWarningService>;
|
||||
let mockOrganizationWarningsService: MockProxy<OrganizationWarningsService>;
|
||||
let mockMemberActionsService: MockProxy<MemberActionsService>;
|
||||
let mockMemberDialogManager: MockProxy<MemberDialogManagerService>;
|
||||
let mockBillingConstraint: MockProxy<BillingConstraintService>;
|
||||
let mockMemberService: MockProxy<OrganizationMembersService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockOrganizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockMemberExportService: MockProxy<MemberExportService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
|
||||
let routeParamsSubject: BehaviorSubject<any>;
|
||||
let queryParamsSubject: BehaviorSubject<any>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
canManageUsers: true,
|
||||
useSecretsManager: true,
|
||||
useResetPassword: true,
|
||||
isProviderUser: false,
|
||||
} as Organization;
|
||||
|
||||
const mockUser = {
|
||||
id: newGuid(),
|
||||
userId: newGuid(),
|
||||
type: OrganizationUserType.User,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
resetPasswordEnrolled: false,
|
||||
accessSecretsManager: false,
|
||||
managedByOrganization: false,
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: false,
|
||||
hasMasterPassword: true,
|
||||
} as OrganizationUserView;
|
||||
|
||||
const mockBillingMetadata = {
|
||||
isSubscriptionUnpaid: false,
|
||||
} as Partial<OrganizationBillingMetadataResponse>;
|
||||
|
||||
beforeEach(async () => {
|
||||
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
|
||||
queryParamsSubject = new BehaviorSubject({});
|
||||
|
||||
mockActivatedRoute = {
|
||||
params: routeParamsSubject.asObservable(),
|
||||
queryParams: queryParamsSubject.asObservable(),
|
||||
} as any;
|
||||
|
||||
mockApiService = mock<ApiService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
mockOrganizationManagementPreferencesService = mock<OrganizationManagementPreferencesService>();
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = {
|
||||
state$: of(false),
|
||||
} as any;
|
||||
|
||||
mockKeyService = mock<KeyService>();
|
||||
mockValidationService = mock<ValidationService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockUserNamePipe = mock<UserNamePipe>();
|
||||
mockUserNamePipe.transform.mockReturnValue("Test User");
|
||||
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockDeleteManagedMemberWarningService = mock<DeleteManagedMemberWarningService>();
|
||||
mockOrganizationWarningsService = mock<OrganizationWarningsService>();
|
||||
mockMemberActionsService = mock<MemberActionsService>();
|
||||
mockMemberDialogManager = mock<MemberDialogManagerService>();
|
||||
mockBillingConstraint = mock<BillingConstraintService>();
|
||||
|
||||
mockMemberService = mock<OrganizationMembersService>();
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockOrganizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
|
||||
mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue(
|
||||
of(mockBillingMetadata),
|
||||
);
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
mockEnvironmentService = mock<EnvironmentService>();
|
||||
mockEnvironmentService.environment$ = of({
|
||||
isCloud: () => false,
|
||||
} as any);
|
||||
|
||||
mockMemberExportService = mock<MemberExportService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [vNextMembersComponent],
|
||||
providers: [
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useValue: mockOrganizationManagementPreferencesService,
|
||||
},
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: ValidationService, useValue: mockValidationService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: UserNamePipe, useValue: mockUserNamePipe },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{
|
||||
provide: DeleteManagedMemberWarningService,
|
||||
useValue: mockDeleteManagedMemberWarningService,
|
||||
},
|
||||
{ provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService },
|
||||
{ provide: MemberActionsService, useValue: mockMemberActionsService },
|
||||
{ provide: MemberDialogManagerService, useValue: mockMemberDialogManager },
|
||||
{ provide: BillingConstraintService, useValue: mockBillingConstraint },
|
||||
{ provide: OrganizationMembersService, useValue: mockMemberService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useValue: mockOrganizationMetadataService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: MemberExportService, useValue: mockMemberExportService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(vNextMembersComponent, {
|
||||
remove: { imports: [] },
|
||||
add: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(vNextMembersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fixture) {
|
||||
fixture.destroy();
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("load", () => {
|
||||
it("should load users and set data source", async () => {
|
||||
const users = [mockUser];
|
||||
mockMemberService.loadUsers.mockResolvedValue(users);
|
||||
|
||||
await component.load(mockOrg);
|
||||
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
expect(component["dataSource"]().data).toEqual(users);
|
||||
expect(component["firstLoaded"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
mockMemberService.loadUsers.mockResolvedValue([]);
|
||||
|
||||
await component.load(mockOrg);
|
||||
|
||||
expect(component["dataSource"]().data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove user when confirmed", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.removeUser.mockResolvedValue({ success: true });
|
||||
|
||||
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
|
||||
|
||||
await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
);
|
||||
expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(removeSpy).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not remove user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.removeUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Remove failed",
|
||||
});
|
||||
|
||||
await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Remove failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Remove failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinvite", () => {
|
||||
it("should reinvite user successfully", async () => {
|
||||
mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true });
|
||||
|
||||
await component.reinvite(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberActionsService.reinviteUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Reinvite failed",
|
||||
});
|
||||
|
||||
await component.reinvite(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Reinvite failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Reinvite failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("should confirm user with auto-confirm enabled", async () => {
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
|
||||
mockMemberActionsService.confirmUser.mockResolvedValue({ success: true });
|
||||
|
||||
// Mock getPublicKeyForConfirm to return a public key
|
||||
const mockPublicKey = new Uint8Array([1, 2, 3, 4]);
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(mockPublicKey);
|
||||
|
||||
const replaceSpy = jest.spyOn(component["dataSource"](), "replaceUser");
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockPublicKey,
|
||||
mockOrg,
|
||||
);
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle null user", async () => {
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
|
||||
|
||||
// Mock getPublicKeyForConfirm to return null
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
|
||||
expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled();
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
|
||||
});
|
||||
|
||||
it("should handle API errors gracefully", async () => {
|
||||
// Mock getPublicKeyForConfirm to return null
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revoke", () => {
|
||||
it("should revoke user when confirmed", async () => {
|
||||
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.revokeUser.mockResolvedValue({ success: true });
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.revoke(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
);
|
||||
expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not revoke user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.revoke(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("should restore user successfully", async () => {
|
||||
mockMemberActionsService.restoreUser.mockResolvedValue({ success: true });
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.restore(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberActionsService.restoreUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Restore failed",
|
||||
});
|
||||
|
||||
await component.restore(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Restore failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Restore failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invite", () => {
|
||||
it("should open invite dialog when seat limit not reached", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
|
||||
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
mockBillingMetadata,
|
||||
);
|
||||
expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
mockBillingMetadata,
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reload organization and refresh metadata cache after successful invite", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
|
||||
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open dialog when seat limit reached", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(true);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkRemove", () => {
|
||||
it("should open bulk remove dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkRemove(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkDelete", () => {
|
||||
it("should open bulk delete dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkDelete(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkRevokeOrRestore", () => {
|
||||
it.each([
|
||||
{ isRevoking: true, action: "revoke" },
|
||||
{ isRevoking: false, action: "restore" },
|
||||
])(
|
||||
"should open bulk $action dialog and reload when isRevoking is $isRevoking",
|
||||
async ({ isRevoking }) => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkRevokeOrRestore(isRevoking, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
users,
|
||||
isRevoking,
|
||||
);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bulkReinvite", () => {
|
||||
it("should reinvite invited users", async () => {
|
||||
const invitedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Invited,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
|
||||
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true });
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser]);
|
||||
expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show error when no invited users selected", async () => {
|
||||
const confirmedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([confirmedUser]);
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "errorOccurred",
|
||||
message: "noSelectedUsersApplicable",
|
||||
});
|
||||
expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors", async () => {
|
||||
const invitedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Invited,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
|
||||
const error = new Error("Bulk reinvite failed");
|
||||
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error });
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockValidationService.showError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkConfirm", () => {
|
||||
it("should open bulk confirm dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkConfirm(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkEnableSM", () => {
|
||||
it("should open bulk enable SM dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
jest.spyOn(component["dataSource"](), "uncheckAllUsers");
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkEnableSM(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
users,
|
||||
);
|
||||
expect(component["dataSource"]().uncheckAllUsers).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("should open account recovery dialog", async () => {
|
||||
mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue(
|
||||
AccountRecoveryDialogResultType.Ok,
|
||||
);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.resetPassword(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockOrg,
|
||||
);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
it("should delete user when confirmed", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.deleteUser.mockResolvedValue({ success: true });
|
||||
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
|
||||
|
||||
await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockOrg,
|
||||
);
|
||||
expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(removeSpy).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not delete user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.deleteUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Delete failed",
|
||||
});
|
||||
|
||||
await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Delete failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Delete failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMemberActionResult", () => {
|
||||
it("should show success toast when result is successful", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "testSuccessKey",
|
||||
});
|
||||
});
|
||||
|
||||
it("should execute side effect when provided and successful", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
const sideEffect = jest.fn();
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
|
||||
|
||||
expect(sideEffect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show error toast when result is not successful", async () => {
|
||||
const result: MemberActionResult = { success: false, error: "Error message" };
|
||||
const sideEffect = jest.fn();
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Error message",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Error message");
|
||||
expect(sideEffect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should propagate error when side effect throws", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
const error = new Error("Side effect failed");
|
||||
const sideEffect = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect),
|
||||
).rejects.toThrow("Side effect failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, computed, Signal } from "@angular/core";
|
||||
import { Component, computed, inject, signal, Signal, WritableSignal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
debounceTime,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
@@ -15,11 +18,8 @@ import {
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
@@ -33,24 +33,24 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
import { BaseMembersComponent } from "../../common/base-members.component";
|
||||
import {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
PeopleTableDataSource,
|
||||
MembersTableDataSource,
|
||||
peopleFilter,
|
||||
showConfirmBanner,
|
||||
} from "../../common/people-table-data-source";
|
||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
@@ -67,8 +67,13 @@ import {
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||
protected statusType = OrganizationUserStatusType;
|
||||
interface BulkMemberFlags {
|
||||
showBulkRestoreUsers: boolean;
|
||||
showBulkRevokeUsers: boolean;
|
||||
showBulkRemoveUsers: boolean;
|
||||
showBulkDeleteUsers: boolean;
|
||||
showBulkConfirmUsers: boolean;
|
||||
showBulkReinviteUsers: boolean;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -77,71 +82,90 @@ class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView>
|
||||
templateUrl: "members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
|
||||
userType = OrganizationUserType;
|
||||
userStatusType = OrganizationUserStatusType;
|
||||
memberTab = MemberDialogTab;
|
||||
protected dataSource: MembersTableDataSource;
|
||||
|
||||
readonly organization: Signal<Organization | undefined>;
|
||||
status: OrganizationUserStatusType | undefined;
|
||||
export class vNextMembersComponent {
|
||||
protected i18nService = inject(I18nService);
|
||||
protected validationService = inject(ValidationService);
|
||||
protected logService = inject(LogService);
|
||||
protected userNamePipe = inject(UserNamePipe);
|
||||
protected dialogService = inject(DialogService);
|
||||
protected toastService = inject(ToastService);
|
||||
private route = inject(ActivatedRoute);
|
||||
protected deleteManagedMemberWarningService = inject(DeleteManagedMemberWarningService);
|
||||
private organizationWarningsService = inject(OrganizationWarningsService);
|
||||
private memberActionsService = inject(MemberActionsService);
|
||||
private memberDialogManager = inject(MemberDialogManagerService);
|
||||
protected billingConstraint = inject(BillingConstraintService);
|
||||
protected memberService = inject(OrganizationMembersService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private accountService = inject(AccountService);
|
||||
private policyService = inject(PolicyService);
|
||||
private policyApiService = inject(PolicyApiServiceAbstraction);
|
||||
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
|
||||
private environmentService = inject(EnvironmentService);
|
||||
private memberExportService = inject(MemberExportService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||
protected userType = OrganizationUserType;
|
||||
protected userStatusType = OrganizationUserStatusType;
|
||||
protected memberTab = MemberDialogTab;
|
||||
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
|
||||
|
||||
protected readonly dataSource: Signal<MembersTableDataSource> = signal(
|
||||
new MembersTableDataSource(this.environmentService),
|
||||
);
|
||||
protected readonly organization: Signal<Organization | undefined>;
|
||||
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
|
||||
|
||||
protected bulkMenuOptions$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map((members) => this.bulkMenuOptions(members)));
|
||||
|
||||
protected showConfirmBanner$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map(() => showConfirmBanner(this.dataSource())));
|
||||
|
||||
protected selectedInvitedCount$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(
|
||||
map(
|
||||
(members) => members.filter((m) => m.status === OrganizationUserStatusType.Invited).length,
|
||||
),
|
||||
);
|
||||
|
||||
protected isSingleInvite$ = this.selectedInvitedCount$.pipe(map((count) => count === 1));
|
||||
|
||||
protected isProcessing = this.memberActionsService.isProcessing;
|
||||
|
||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||
() => this.organization()?.useSecretsManager ?? false,
|
||||
);
|
||||
|
||||
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
||||
() => this.organization()?.canManageUsers ?? false,
|
||||
);
|
||||
|
||||
protected readonly bulkReinviteUIEnabled = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI),
|
||||
);
|
||||
|
||||
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
|
||||
|
||||
protected resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 66;
|
||||
protected rowHeightClass = `tw-h-[66px]`;
|
||||
|
||||
constructor(
|
||||
apiService: ApiService,
|
||||
i18nService: I18nService,
|
||||
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
keyService: KeyService,
|
||||
validationService: ValidationService,
|
||||
logService: LogService,
|
||||
userNamePipe: UserNamePipe,
|
||||
dialogService: DialogService,
|
||||
toastService: ToastService,
|
||||
private route: ActivatedRoute,
|
||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private memberActionsService: MemberActionsService,
|
||||
private memberDialogManager: MemberDialogManagerService,
|
||||
protected billingConstraint: BillingConstraintService,
|
||||
protected memberService: OrganizationMembersService,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private policyService: PolicyService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
private memberExportService: MemberExportService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
constructor() {
|
||||
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(
|
||||
([searchText, status]) => (this.dataSource().filter = peopleFilter(searchText, status)),
|
||||
);
|
||||
|
||||
const organization$ = this.route.params.pipe(
|
||||
concatMap((params) =>
|
||||
@@ -184,7 +208,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
this.searchControl.setValue(qParams.search);
|
||||
|
||||
if (qParams.viewEvents != null) {
|
||||
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
||||
const user = this.dataSource().data.filter((u) => u.id === qParams.viewEvents);
|
||||
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
|
||||
this.openEventsDialog(user[0], organization!);
|
||||
}
|
||||
@@ -218,80 +242,62 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
|
||||
}
|
||||
|
||||
override async load(organization: Organization) {
|
||||
await super.load(organization);
|
||||
async load(organization: Organization) {
|
||||
const response = await this.memberService.loadUsers(organization);
|
||||
this.dataSource().data = response;
|
||||
this.firstLoaded.set(true);
|
||||
}
|
||||
|
||||
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
|
||||
return await this.memberService.loadUsers(organization);
|
||||
}
|
||||
|
||||
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.removeUser(organization, id);
|
||||
}
|
||||
|
||||
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.revokeUser(organization, id);
|
||||
}
|
||||
|
||||
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.restoreUser(organization, id);
|
||||
}
|
||||
|
||||
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.reinviteUser(organization, id);
|
||||
}
|
||||
|
||||
async confirmUser(
|
||||
user: OrganizationUserView,
|
||||
publicKey: Uint8Array,
|
||||
organization: Organization,
|
||||
): Promise<MemberActionResult> {
|
||||
return await this.memberActionsService.confirmUser(user, publicKey, organization);
|
||||
}
|
||||
|
||||
async revoke(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.revokeUserConfirmationDialog(user);
|
||||
async remove(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.actionPromise = this.revokeUser(user.id, organization);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
|
||||
});
|
||||
await this.load(organization);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
const result = await this.memberActionsService.removeUser(organization, user.id);
|
||||
const sideEffect = () => this.dataSource().removeUser(user);
|
||||
await this.handleMemberActionResult(result, "removedUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
async reinvite(user: OrganizationUserView, organization: Organization) {
|
||||
const result = await this.memberActionsService.reinviteUser(organization, user.id);
|
||||
await this.handleMemberActionResult(result, "hasBeenReinvited", user);
|
||||
}
|
||||
|
||||
async confirm(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmUserSideEffect = () => {
|
||||
user.status = this.userStatusType.Confirmed;
|
||||
this.dataSource().replaceUser(user);
|
||||
};
|
||||
|
||||
const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user);
|
||||
|
||||
if (publicKeyResult == null) {
|
||||
this.logService.warning("Public key not found");
|
||||
return;
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
|
||||
const result = await this.memberActionsService.confirmUser(user, publicKeyResult, organization);
|
||||
await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect);
|
||||
}
|
||||
|
||||
async revoke(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.memberActionsService.revokeUser(organization, user.id);
|
||||
const sideEffect = async () => await this.load(organization);
|
||||
await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
async restore(user: OrganizationUserView, organization: Organization) {
|
||||
this.actionPromise = this.restoreUser(user.id, organization);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
|
||||
});
|
||||
await this.load(organization);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
const result = await this.memberActionsService.restoreUser(organization, user.id);
|
||||
const sideEffect = async () => await this.load(organization);
|
||||
await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
@@ -307,7 +313,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
}
|
||||
|
||||
showEnrolledStatus(
|
||||
orgUser: OrganizationUserUserDetailsResponse,
|
||||
orgUser: OrganizationUserView,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
@@ -318,9 +324,15 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInviteDialog(organization: Organization) {
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
|
||||
if (await this.billingConstraint.seatLimitReached(seatLimitResult, organization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allUserEmails = this.dataSource().data?.map((user) => user.email) ?? [];
|
||||
|
||||
const result = await this.memberDialogManager.openInviteDialog(
|
||||
organization,
|
||||
@@ -330,14 +342,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
if (result === MemberDialogResult.Saved) {
|
||||
await this.load(organization);
|
||||
}
|
||||
}
|
||||
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
|
||||
await this.handleInviteDialog(organization);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
}
|
||||
}
|
||||
@@ -358,7 +362,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
switch (result) {
|
||||
case MemberDialogResult.Deleted:
|
||||
this.dataSource.removeUser(user);
|
||||
this.dataSource().removeUser(user);
|
||||
break;
|
||||
case MemberDialogResult.Saved:
|
||||
case MemberDialogResult.Revoked:
|
||||
@@ -369,57 +373,30 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
}
|
||||
|
||||
async bulkRemove(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkDelete(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkRevoke(organization: Organization) {
|
||||
await this.bulkRevokeOrRestore(true, organization);
|
||||
}
|
||||
|
||||
async bulkRestore(organization: Organization) {
|
||||
await this.bulkRevokeOrRestore(false, organization);
|
||||
}
|
||||
|
||||
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let users: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource().getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
users = this.dataSource().getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
|
||||
@@ -427,10 +404,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
// Capture the original count BEFORE enforcing the limit
|
||||
const originalInvitedCount = allInvitedUsers.length;
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
// In cloud environments, limit invited users and uncheck the excess
|
||||
let filteredUsers: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource.limitAndUncheckExcess(
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled() && !this.bulkReinviteUIEnabled()) {
|
||||
filteredUsers = this.dataSource().limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
@@ -447,70 +424,62 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.memberActionsService.bulkReinvite(
|
||||
organization,
|
||||
filteredUsers.map((user) => user.id as UserId),
|
||||
);
|
||||
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
if (!result.successful) {
|
||||
this.validationService.showError(result.failed);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
|
||||
// In cloud environments, show toast instead of dialog
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
// Only show limited toast if feature flag is disabled and limit was applied
|
||||
if (!this.bulkReinviteUIEnabled() && selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message:
|
||||
invitedCount === 1
|
||||
? this.i18nService.t("reinviteSuccessToast")
|
||||
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// In self-hosted environments, show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
|
||||
this.dataSource().uncheckAllUsers();
|
||||
}
|
||||
|
||||
async bulkConfirm(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkEnableSM(organization: Organization) {
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
|
||||
|
||||
this.dataSource.uncheckAllUsers();
|
||||
this.dataSource().uncheckAllUsers();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
@@ -538,14 +507,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
return;
|
||||
}
|
||||
|
||||
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
|
||||
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
|
||||
}
|
||||
|
||||
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
|
||||
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
|
||||
}
|
||||
|
||||
async deleteUser(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
|
||||
user,
|
||||
@@ -556,80 +517,72 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
return false;
|
||||
}
|
||||
|
||||
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
|
||||
try {
|
||||
const result = await this.actionPromise;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const result = await this.memberActionsService.deleteUser(organization, user.id);
|
||||
await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => {
|
||||
this.dataSource().removeUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
async handleMemberActionResult(
|
||||
result: MemberActionResult,
|
||||
successKey: string,
|
||||
user: OrganizationUserView,
|
||||
sideEffect?: () => void | Promise<void>,
|
||||
) {
|
||||
if (result.error != null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: result.error,
|
||||
});
|
||||
this.logService.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
|
||||
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
|
||||
});
|
||||
this.dataSource.removeUser(user);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
|
||||
if (sideEffect) {
|
||||
await sideEffect();
|
||||
}
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
get showBulkRestoreUsers(): boolean {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.status == this.userStatusType.Revoked);
|
||||
}
|
||||
|
||||
get showBulkRevokeUsers(): boolean {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.status != this.userStatusType.Revoked);
|
||||
}
|
||||
|
||||
get showBulkRemoveUsers(): boolean {
|
||||
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
|
||||
}
|
||||
|
||||
get showBulkDeleteUsers(): boolean {
|
||||
private bulkMenuOptions(members: OrganizationUserView[]): BulkMemberFlags {
|
||||
const validStatuses = [
|
||||
this.userStatusType.Accepted,
|
||||
this.userStatusType.Confirmed,
|
||||
this.userStatusType.Revoked,
|
||||
OrganizationUserStatusType.Accepted,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
OrganizationUserStatusType.Revoked,
|
||||
];
|
||||
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
|
||||
const result = {
|
||||
showBulkConfirmUsers: members.every((m) => m.status == OrganizationUserStatusType.Accepted),
|
||||
showBulkReinviteUsers: members.every((m) => m.status == OrganizationUserStatusType.Invited),
|
||||
showBulkRestoreUsers: members.every((m) => m.status == OrganizationUserStatusType.Revoked),
|
||||
showBulkRevokeUsers: members.every((m) => m.status != OrganizationUserStatusType.Revoked),
|
||||
showBulkRemoveUsers: members.every((m) => !m.managedByOrganization),
|
||||
showBulkDeleteUsers: members.every(
|
||||
(m) => m.managedByOrganization && validStatuses.includes(m.status),
|
||||
),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
exportMembers = async (): Promise<void> => {
|
||||
try {
|
||||
const members = this.dataSource.data;
|
||||
if (!members || members.length === 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("noMembersToExport"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const csvData = this.memberExportService.getMemberExport(members);
|
||||
const fileName = this.memberExportService.getFileName("org-members");
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: fileName,
|
||||
blobData: csvData,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
exportMembers = () => {
|
||||
const result = this.memberExportService.getMemberExport(this.dataSource().data);
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("dataExportSuccess"),
|
||||
});
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
this.logService.error(`Failed to export members: ${e}`);
|
||||
}
|
||||
|
||||
if (result.error != null) {
|
||||
this.validationService.showError(result.error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,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 { IconModule, 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";
|
||||
|
||||
@@ -13,12 +13,15 @@ import { SharedOrganizationModule } from "../shared";
|
||||
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 { BulkProgressDialogComponent } from "./components/bulk/bulk-progress-dialog.component";
|
||||
import { BulkReinviteFailureDialogComponent } from "./components/bulk/bulk-reinvite-failure-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 { UserDialogModule } from "./components/member-dialog";
|
||||
import { MembersComponent } from "./deprecated_members.component";
|
||||
import { MembersRoutingModule } from "./members-routing.module";
|
||||
import { MembersComponent } from "./members.component";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
import { UserStatusPipe } from "./pipes";
|
||||
import {
|
||||
OrganizationMembersService,
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
PasswordStrengthV2Component,
|
||||
ScrollLayoutDirective,
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
IconModule,
|
||||
],
|
||||
declarations: [
|
||||
BulkConfirmDialogComponent,
|
||||
@@ -45,7 +49,10 @@ import {
|
||||
BulkRemoveDialogComponent,
|
||||
BulkRestoreRevokeComponent,
|
||||
BulkStatusComponent,
|
||||
BulkProgressDialogComponent,
|
||||
BulkReinviteFailureDialogComponent,
|
||||
MembersComponent,
|
||||
vNextMembersComponent,
|
||||
BulkDeleteDialogComponent,
|
||||
UserStatusPipe,
|
||||
],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { of, throwError } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import {
|
||||
OrganizationUserType,
|
||||
OrganizationUserStatusType,
|
||||
@@ -14,10 +18,15 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service";
|
||||
|
||||
import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service";
|
||||
|
||||
@@ -27,6 +36,7 @@ describe("MemberActionsService", () => {
|
||||
let organizationUserService: MockProxy<OrganizationUserService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
|
||||
let memberDialogManager: MockProxy<MemberDialogManagerService>;
|
||||
|
||||
const organizationId = newGuid() as OrganizationId;
|
||||
const userIdToManage = newGuid();
|
||||
@@ -39,6 +49,7 @@ describe("MemberActionsService", () => {
|
||||
organizationUserService = mock<OrganizationUserService>();
|
||||
configService = mock<ConfigService>();
|
||||
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
|
||||
memberDialogManager = mock<MemberDialogManagerService>();
|
||||
|
||||
mockOrganization = {
|
||||
id: organizationId,
|
||||
@@ -56,12 +67,31 @@ describe("MemberActionsService", () => {
|
||||
resetPasswordEnrolled: true,
|
||||
} as OrganizationUserView;
|
||||
|
||||
service = new MemberActionsService(
|
||||
organizationUserApiService,
|
||||
organizationUserService,
|
||||
configService,
|
||||
organizationMetadataService,
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
MemberActionsService,
|
||||
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
|
||||
{ provide: OrganizationUserService, useValue: organizationUserService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useValue: organizationMetadataService,
|
||||
},
|
||||
{ provide: ApiService, useValue: mock<ApiService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: KeyService, useValue: mock<KeyService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useValue: mock<OrganizationManagementPreferencesService>(),
|
||||
},
|
||||
{ provide: UserNamePipe, useValue: mock<UserNamePipe>() },
|
||||
{ provide: MemberDialogManagerService, useValue: memberDialogManager },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(MemberActionsService);
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
@@ -154,25 +184,64 @@ describe("MemberActionsService", () => {
|
||||
});
|
||||
|
||||
describe("restoreUser", () => {
|
||||
it("should successfully restore a user", async () => {
|
||||
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
|
||||
describe("when feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
});
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
it("should call organizationUserService.restoreUser", async () => {
|
||||
organizationUserService.restoreUser.mockReturnValue(of(undefined));
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdToManage,
|
||||
);
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserService.restoreUser).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
userIdToManage,
|
||||
);
|
||||
expect(organizationUserApiService.restoreOrganizationUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors from organizationUserService.restoreUser", async () => {
|
||||
const errorMessage = "Restore failed";
|
||||
organizationUserService.restoreUser.mockReturnValue(
|
||||
throwError(() => new Error(errorMessage)),
|
||||
);
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: false, error: errorMessage });
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle restore errors", async () => {
|
||||
const errorMessage = "Restore failed";
|
||||
organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage));
|
||||
describe("when feature flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
it("should call organizationUserApiService.restoreOrganizationUser", async () => {
|
||||
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
|
||||
|
||||
expect(result).toEqual({ success: false, error: errorMessage });
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdToManage,
|
||||
);
|
||||
expect(organizationUserService.restoreUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,17 +324,284 @@ describe("MemberActionsService", () => {
|
||||
});
|
||||
|
||||
describe("bulkReinvite", () => {
|
||||
const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId];
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
|
||||
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdsBatch,
|
||||
);
|
||||
});
|
||||
|
||||
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toHaveLength(totalUsers);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
organizationId,
|
||||
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
organizationId,
|
||||
userIdsBatch.slice(REQUESTS_PER_BATCH),
|
||||
);
|
||||
});
|
||||
|
||||
it("should aggregate results across multiple successful batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toHaveLength(totalUsers);
|
||||
expect(result.successful!.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data);
|
||||
expect(result.successful!.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle mixed individual errors across multiple batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 4;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
|
||||
id,
|
||||
error: index % 10 === 0 ? "Rate limit exceeded" : null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
|
||||
],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
|
||||
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
|
||||
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
|
||||
const expectedFailuresInBatch2 = 2;
|
||||
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
|
||||
const expectedSuccesses = totalUsers - expectedTotalFailures;
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toHaveLength(expectedSuccesses);
|
||||
expect(result.failed).toHaveLength(expectedTotalFailures);
|
||||
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
|
||||
});
|
||||
|
||||
it("should aggregate all failures when all batches fail", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
const errorMessage = "All batches failed";
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeUndefined();
|
||||
expect(result.failed).toHaveLength(totalUsers);
|
||||
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should handle empty data in batch response", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should process batches sequentially in order", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH * 2;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
const callOrder: number[] = [];
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
|
||||
async (orgId, ids) => {
|
||||
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
|
||||
callOrder.push(batchIndex);
|
||||
|
||||
return new ListResponse(
|
||||
{
|
||||
data: ids.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(callOrder).toEqual([1, 2]);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("with BulkReinviteUI feature flag enabled", () => {
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
describe("when feature flag is false", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
mockDialogService = TestBed.inject(DialogService) as MockProxy<DialogService>;
|
||||
mockI18nService = TestBed.inject(I18nService) as MockProxy<I18nService>;
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
});
|
||||
|
||||
it("should successfully reinvite multiple users", async () => {
|
||||
const mockResponse = new ListResponse(
|
||||
it("should open progress dialog when user count exceeds REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
const mockDialogRef = { closed: of(undefined) };
|
||||
memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIds.map((id) => ({
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
@@ -274,39 +610,35 @@ describe("MemberActionsService", () => {
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIds);
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toEqual(mockResponse);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIds,
|
||||
await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled();
|
||||
expect(memberDialogManager.openBulkProgressDialog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
totalUsers,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle bulk reinvite errors", async () => {
|
||||
const errorMessage = "Bulk reinvite failed";
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
it("should not open progress dialog when user count is or below REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
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("when feature flag is true (batching behavior)", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
});
|
||||
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
|
||||
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.map((id) => ({
|
||||
@@ -320,23 +652,47 @@ describe("MemberActionsService", () => {
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdsBatch,
|
||||
);
|
||||
expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled();
|
||||
expect(memberDialogManager.openBulkProgressDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
|
||||
it("should open failure dialog when there are failures", async () => {
|
||||
const totalUsers = 10;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.map((id) => ({
|
||||
id,
|
||||
error: "error",
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(memberDialogManager.openBulkReinviteFailureDialog).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
users,
|
||||
result,
|
||||
);
|
||||
expect(result.failed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should process batches when exceeding REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
|
||||
|
||||
const mockDialogRef = { closed: of(undefined) };
|
||||
memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
@@ -364,195 +720,8 @@ describe("MemberActionsService", () => {
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
await service.bulkReinvite(mockOrganization, users);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
organizationId,
|
||||
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
organizationId,
|
||||
userIdsBatch.slice(REQUESTS_PER_BATCH),
|
||||
);
|
||||
});
|
||||
|
||||
it("should aggregate results across multiple successful batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(
|
||||
mockResponse1.data,
|
||||
);
|
||||
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle mixed individual errors across multiple batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 4;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
|
||||
id,
|
||||
error: index % 10 === 0 ? "Rate limit exceeded" : null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
|
||||
],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
|
||||
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
|
||||
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
|
||||
const expectedFailuresInBatch2 = 2;
|
||||
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
|
||||
const expectedSuccesses = totalUsers - expectedTotalFailures;
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(expectedSuccesses);
|
||||
expect(result.failed).toHaveLength(expectedTotalFailures);
|
||||
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
|
||||
});
|
||||
|
||||
it("should aggregate all failures when all batches fail", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const errorMessage = "All batches failed";
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(result.successful).toBeUndefined();
|
||||
expect(result.failed).toHaveLength(totalUsers);
|
||||
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty data in batch response", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should process batches sequentially in order", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH * 2;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const callOrder: number[] = [];
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
|
||||
async (orgId, ids) => {
|
||||
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
|
||||
callOrder.push(batchIndex);
|
||||
|
||||
return new ListResponse(
|
||||
{
|
||||
data: ids.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(callOrder).toEqual([1, 2]);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
@@ -660,4 +829,26 @@ describe("MemberActionsService", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessing signal", () => {
|
||||
it("should be false initially", () => {
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false after operation completes successfully", async () => {
|
||||
organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined);
|
||||
|
||||
await service.removeUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false after operation fails", async () => {
|
||||
organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error("Failed"));
|
||||
|
||||
await service.removeUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { inject, Injectable, signal, WritableSignal } from "@angular/core";
|
||||
import { lastValueFrom, firstValueFrom, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import {
|
||||
OrganizationUserType,
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
import { UserConfirmComponent } from "../../../manage/user-confirm.component";
|
||||
import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service";
|
||||
|
||||
export const REQUESTS_PER_BATCH = 500;
|
||||
|
||||
@@ -26,19 +36,48 @@ export interface MemberActionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BulkActionResult {
|
||||
successful?: ListResponse<OrganizationUserBulkResponse>;
|
||||
failed: { id: string; error: string }[];
|
||||
export class BulkActionResult {
|
||||
constructor() {
|
||||
this.failed = [];
|
||||
}
|
||||
|
||||
successful?: OrganizationUserBulkResponse[];
|
||||
failed: { id: string; error: string }[] = [];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MemberActionsService {
|
||||
constructor(
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private configService: ConfigService,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
) {}
|
||||
private organizationUserApiService = inject(OrganizationUserApiService);
|
||||
private organizationUserService = inject(OrganizationUserService);
|
||||
private configService = inject(ConfigService);
|
||||
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
|
||||
private apiService = inject(ApiService);
|
||||
private dialogService = inject(DialogService);
|
||||
private keyService = inject(KeyService);
|
||||
private logService = inject(LogService);
|
||||
private orgManagementPrefs = inject(OrganizationManagementPreferencesService);
|
||||
private userNamePipe = inject(UserNamePipe);
|
||||
private memberDialogManager = inject(MemberDialogManagerService);
|
||||
|
||||
readonly isProcessing = signal(false);
|
||||
|
||||
private startProcessing(length?: number): void {
|
||||
this.isProcessing.set(true);
|
||||
if (length != null && length > REQUESTS_PER_BATCH) {
|
||||
this.memberDialogManager
|
||||
.openBulkProgressDialog(this.progressCount, length)
|
||||
.closed.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
this.progressCount.set(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private endProcessing(): void {
|
||||
this.isProcessing.set(false);
|
||||
}
|
||||
|
||||
private readonly progressCount: WritableSignal<number> = signal(0);
|
||||
|
||||
async inviteUser(
|
||||
organization: Organization,
|
||||
@@ -48,6 +87,7 @@ export class MemberActionsService {
|
||||
collections?: any[],
|
||||
groups?: string[],
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
|
||||
emails: [email],
|
||||
@@ -60,55 +100,86 @@ export class MemberActionsService {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async removeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
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) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async revokeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
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) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
|
||||
await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore).pipe(
|
||||
switchMap((enabled) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.restoreUser(organization, userId);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreOrganizationUser(
|
||||
organization.id,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
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) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async reinviteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +188,7 @@ export class MemberActionsService {
|
||||
publicKey: Uint8Array,
|
||||
organization: Organization,
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.organizationUserService.confirmUser(organization, user.id, publicKey),
|
||||
@@ -124,37 +196,47 @@ export class MemberActionsService {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
|
||||
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
async bulkReinvite(
|
||||
organization: Organization,
|
||||
users: OrganizationUserView[],
|
||||
): Promise<BulkActionResult> {
|
||||
let result = new BulkActionResult();
|
||||
const bulkReinviteUIEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI),
|
||||
);
|
||||
if (increaseBulkReinviteLimitForCloud) {
|
||||
return await this.vNextBulkReinvite(organization, userIds);
|
||||
|
||||
if (bulkReinviteUIEnabled) {
|
||||
this.startProcessing(users.length);
|
||||
} else {
|
||||
try {
|
||||
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
|
||||
this.startProcessing();
|
||||
}
|
||||
|
||||
try {
|
||||
result = await this.processBatchedOperation(users, REQUESTS_PER_BATCH, (userBatch) => {
|
||||
const userIds = userBatch.map((u) => u.id);
|
||||
return 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) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async vNextBulkReinvite(
|
||||
organization: Organization,
|
||||
userIds: UserId[],
|
||||
): Promise<BulkActionResult> {
|
||||
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
|
||||
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
|
||||
);
|
||||
if (bulkReinviteUIEnabled && result.failed.length > 0) {
|
||||
this.memberDialogManager.openBulkReinviteFailureDialog(organization, users, result);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed = users.map((user) => ({
|
||||
id: user.id,
|
||||
error: (error as Error).message ?? String(error),
|
||||
}));
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
@@ -191,21 +273,23 @@ export class MemberActionsService {
|
||||
|
||||
/**
|
||||
* Processes user IDs in sequential batches and aggregates results.
|
||||
* @param userIds - Array of user IDs to process
|
||||
* @param users - Array of users to process
|
||||
* @param batchSize - Number of IDs to process per batch
|
||||
* @param processBatch - Async function that processes a single batch and returns the result
|
||||
* @param processBatch - Async function that processes a single batch from the provided param `users` and returns the result.
|
||||
* @returns Aggregated bulk action result
|
||||
*/
|
||||
private async processBatchedOperation(
|
||||
userIds: UserId[],
|
||||
users: OrganizationUserView[],
|
||||
batchSize: number,
|
||||
processBatch: (batch: string[]) => Promise<ListResponse<OrganizationUserBulkResponse>>,
|
||||
processBatch: (
|
||||
batch: OrganizationUserView[],
|
||||
) => Promise<ListResponse<OrganizationUserBulkResponse>>,
|
||||
): Promise<BulkActionResult> {
|
||||
const allSuccessful: OrganizationUserBulkResponse[] = [];
|
||||
const allFailed: { id: string; error: string }[] = [];
|
||||
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
for (let i = 0; i < users.length; i += batchSize) {
|
||||
const batch = users.slice(i, i + batchSize);
|
||||
|
||||
try {
|
||||
const result = await processBatch(batch);
|
||||
@@ -221,19 +305,65 @@ export class MemberActionsService {
|
||||
}
|
||||
} catch (error) {
|
||||
allFailed.push(
|
||||
...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
|
||||
...batch.map((user) => ({
|
||||
id: user.id,
|
||||
error: (error as Error).message ?? String(error),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.progressCount.update((value) => value + batch.length);
|
||||
}
|
||||
|
||||
const successful =
|
||||
allSuccessful.length > 0
|
||||
? new ListResponse(allSuccessful, OrganizationUserBulkResponse)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
successful,
|
||||
successful: allSuccessful.length > 0 ? allSuccessful : undefined,
|
||||
failed: allFailed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dialog workflow that returns the public key when the user accepts the selected confirmation
|
||||
* action.
|
||||
*
|
||||
* @param user - The user to confirm (must implement ConfirmableUser interface)
|
||||
* @param userNamePipe - Pipe to transform user names for display
|
||||
* @param orgManagementPrefs - Service providing organization management preferences
|
||||
* @returns Promise containing the pulic key that resolves when the confirm action is accepted
|
||||
* or undefined when cancelled
|
||||
*/
|
||||
async getPublicKeyForConfirm(
|
||||
user: OrganizationUserView | ProviderUser,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
try {
|
||||
assertNonNullish(user, "Cannot confirm null user.");
|
||||
|
||||
const autoConfirmFingerPrint = await firstValueFrom(
|
||||
this.orgManagementPrefs.autoConfirmFingerPrints.state$,
|
||||
);
|
||||
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
|
||||
if (autoConfirmFingerPrint == null || !autoConfirmFingerPrint) {
|
||||
const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey);
|
||||
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
|
||||
|
||||
const confirmed = UserConfirmComponent.open(this.dialogService, {
|
||||
data: {
|
||||
name: this.userNamePipe.transform(user),
|
||||
userId: user.userId,
|
||||
publicKey: publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!(await lastValueFrom(confirmed.closed))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
} catch (e) {
|
||||
this.logService.error(`Handled exception: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Injectable, WritableSignal } from "@angular/core";
|
||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
@@ -7,7 +7,7 @@ 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 { CenterPositionStrategy, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
import { openEntityEventsDialog } from "../../../manage/entity-events.component";
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
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 { BulkProgressDialogComponent } from "../../components/bulk/bulk-progress-dialog.component";
|
||||
import { BulkReinviteFailureDialogComponent } from "../../components/bulk/bulk-reinvite-failure-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";
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
openUserAddEditDialog,
|
||||
} from "../../components/member-dialog";
|
||||
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
|
||||
import { BulkActionResult } from "../member-actions/member-actions.service";
|
||||
|
||||
@Injectable()
|
||||
export class MemberDialogManagerService {
|
||||
@@ -319,4 +322,33 @@ export class MemberDialogManagerService {
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
openBulkProgressDialog(progress: WritableSignal<number>, allCount: number) {
|
||||
return this.dialogService.open<BulkProgressDialogComponent>(BulkProgressDialogComponent, {
|
||||
disableClose: true,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
data: {
|
||||
progress,
|
||||
allCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBulkReinviteFailureDialog(
|
||||
organization: Organization,
|
||||
users: OrganizationUserView[],
|
||||
result: BulkActionResult,
|
||||
) {
|
||||
return this.dialogService.open<BulkReinviteFailureDialogComponent>(
|
||||
BulkReinviteFailureDialogComponent,
|
||||
{
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
data: {
|
||||
organization,
|
||||
users,
|
||||
result,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { OrganizationUserView } from "../../../core";
|
||||
import { UserStatusPipe } from "../../pipes";
|
||||
@@ -16,9 +18,13 @@ import { MemberExportService } from "./member-export.service";
|
||||
describe("MemberExportService", () => {
|
||||
let service: MemberExportService;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let fileDownloadService: MockProxy<FileDownloadService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18nService = mock<I18nService>();
|
||||
fileDownloadService = mock<FileDownloadService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Setup common i18n translations
|
||||
i18nService.t.mockImplementation((key: string) => {
|
||||
@@ -44,9 +50,12 @@ describe("MemberExportService", () => {
|
||||
custom: "Custom",
|
||||
// Boolean states
|
||||
enabled: "Enabled",
|
||||
optionEnabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
enrolled: "Enrolled",
|
||||
notEnrolled: "Not Enrolled",
|
||||
// Error messages
|
||||
noMembersToExport: "No members to export",
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -54,6 +63,8 @@ describe("MemberExportService", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
MemberExportService,
|
||||
{ provide: FileDownloadService, useValue: fileDownloadService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
UserTypePipe,
|
||||
UserStatusPipe,
|
||||
@@ -88,8 +99,18 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(fileDownloadService.download).toHaveBeenCalledTimes(1);
|
||||
|
||||
const downloadCall = fileDownloadService.download.mock.calls[0][0];
|
||||
expect(downloadCall.fileName).toContain("org-members");
|
||||
expect(downloadCall.fileName).toContain(".csv");
|
||||
expect(downloadCall.blobOptions).toEqual({ type: "text/plain" });
|
||||
|
||||
const csvData = downloadCall.blobData as string;
|
||||
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
|
||||
expect(csvData).toContain("user1@example.com");
|
||||
expect(csvData).toContain("User One");
|
||||
@@ -114,8 +135,12 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fileDownloadService.download).toHaveBeenCalled();
|
||||
|
||||
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
|
||||
expect(csvData).toContain("user@example.com");
|
||||
// Empty name is represented as an empty field in CSV
|
||||
expect(csvData).toContain("user@example.com,,Confirmed");
|
||||
@@ -135,17 +160,23 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fileDownloadService.download).toHaveBeenCalled();
|
||||
|
||||
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
|
||||
expect(csvData).toContain("user@example.com");
|
||||
expect(csvData).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle empty members array", () => {
|
||||
const csvData = service.getMemberExport([]);
|
||||
const result = service.getMemberExport([]);
|
||||
|
||||
// When array is empty, papaparse returns an empty string
|
||||
expect(csvData).toBe("");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toBe("No members to export");
|
||||
expect(fileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core";
|
||||
import * as papa from "papaparse";
|
||||
|
||||
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||
|
||||
import { OrganizationUserView } from "../../../core";
|
||||
@@ -10,40 +12,71 @@ import { UserStatusPipe } from "../../pipes";
|
||||
|
||||
import { MemberExport } from "./member.export";
|
||||
|
||||
export interface MemberExportResult {
|
||||
success: boolean;
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MemberExportService {
|
||||
private i18nService = inject(I18nService);
|
||||
private userTypePipe = inject(UserTypePipe);
|
||||
private userStatusPipe = inject(UserStatusPipe);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
getMemberExport(members: OrganizationUserView[]): string {
|
||||
const exportData = members.map((m) =>
|
||||
MemberExport.fromOrganizationUserView(
|
||||
this.i18nService,
|
||||
this.userTypePipe,
|
||||
this.userStatusPipe,
|
||||
m,
|
||||
),
|
||||
);
|
||||
getMemberExport(data: OrganizationUserView[]): MemberExportResult {
|
||||
try {
|
||||
const members = data;
|
||||
if (!members || members.length === 0) {
|
||||
return { success: false, error: { message: this.i18nService.t("noMembersToExport") } };
|
||||
}
|
||||
|
||||
const headers: string[] = [
|
||||
this.i18nService.t("email"),
|
||||
this.i18nService.t("name"),
|
||||
this.i18nService.t("status"),
|
||||
this.i18nService.t("role"),
|
||||
this.i18nService.t("twoStepLogin"),
|
||||
this.i18nService.t("accountRecovery"),
|
||||
this.i18nService.t("secretsManager"),
|
||||
this.i18nService.t("groups"),
|
||||
];
|
||||
const exportData = members.map((m) =>
|
||||
MemberExport.fromOrganizationUserView(
|
||||
this.i18nService,
|
||||
this.userTypePipe,
|
||||
this.userStatusPipe,
|
||||
m,
|
||||
),
|
||||
);
|
||||
|
||||
return papa.unparse(exportData, {
|
||||
columns: headers,
|
||||
header: true,
|
||||
});
|
||||
const headers: string[] = [
|
||||
this.i18nService.t("email"),
|
||||
this.i18nService.t("name"),
|
||||
this.i18nService.t("status"),
|
||||
this.i18nService.t("role"),
|
||||
this.i18nService.t("twoStepLogin"),
|
||||
this.i18nService.t("accountRecovery"),
|
||||
this.i18nService.t("secretsManager"),
|
||||
this.i18nService.t("groups"),
|
||||
];
|
||||
|
||||
const csvData = papa.unparse(exportData, {
|
||||
columns: headers,
|
||||
header: true,
|
||||
});
|
||||
|
||||
const fileName = this.getFileName("org-members");
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: fileName,
|
||||
blobData: csvData,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to export members: ${error}`);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : this.i18nService.t("unexpectedError");
|
||||
|
||||
return { success: false, error: { message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
getFileName(prefix: string | null = null, extension = "csv"): string {
|
||||
private getFileName(prefix: string | null = null, extension = "csv"): string {
|
||||
return ExportHelper.getFileName(prefix ?? "", extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs";
|
||||
|
||||
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
Collection,
|
||||
CollectionData,
|
||||
CollectionDetailsResponse,
|
||||
CollectionService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordDetailsResponse,
|
||||
OrganizationUserResetPasswordRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
|
||||
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
||||
|
||||
@@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeAll(() => {
|
||||
keyService = mock<KeyService>();
|
||||
@@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
organizationApiService = mock<OrganizationApiService>();
|
||||
i18nService = mock<I18nService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
sut = new OrganizationUserResetPasswordService(
|
||||
keyService,
|
||||
@@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
organizationApiService,
|
||||
i18nService,
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetMasterPassword", () => {
|
||||
/**
|
||||
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are
|
||||
* any imports/properties in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false;
|
||||
|
||||
const mockNewMP = "new-password";
|
||||
const mockEmail = "test@example.com";
|
||||
const mockOrgUserId = "test-org-user-id";
|
||||
const mockOrgId = "test-org-id";
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
|
||||
);
|
||||
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
|
||||
new OrganizationUserResetPasswordDetailsResponse({
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
@@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
|
||||
// Mock sut method parameters
|
||||
const newMasterPassword = "new-master-password";
|
||||
const email = "user@example.com";
|
||||
const orgUserId = "org-user-id";
|
||||
const orgId = "org-id" as OrganizationId;
|
||||
|
||||
// Mock feature flag value
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true;
|
||||
|
||||
// Mock method data
|
||||
let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse;
|
||||
let salt: MasterPasswordSalt;
|
||||
let kdfConfig: KdfConfig;
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
let userKey: UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock feature flag value
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
|
||||
);
|
||||
|
||||
// Mock method data
|
||||
kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
|
||||
organizationUserResetPasswordDetailsResponse =
|
||||
new OrganizationUserResetPasswordDetailsResponse({
|
||||
organizationUserId: orgUserId,
|
||||
kdf: kdfConfig.kdfType,
|
||||
kdfIterations: kdfConfig.iterations,
|
||||
resetPasswordKey: "test-reset-password-key",
|
||||
encryptedPrivateKey: "test-encrypted-private-key",
|
||||
});
|
||||
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
|
||||
organizationUserResetPasswordDetailsResponse,
|
||||
);
|
||||
|
||||
const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1);
|
||||
const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey;
|
||||
|
||||
keyService.orgKeys$.mockReturnValue(
|
||||
of({ [orgId]: mockDecryptedOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
);
|
||||
|
||||
const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes);
|
||||
|
||||
const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3);
|
||||
const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes);
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey`
|
||||
userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey.
|
||||
|
||||
salt = email as MasterPasswordSalt;
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(salt);
|
||||
|
||||
authenticationData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
|
||||
unlockData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
});
|
||||
|
||||
it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => {
|
||||
// Arrange
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error if the org key cannot be found", async () => {
|
||||
// Arrange
|
||||
keyService.orgKeys$.mockReturnValue(of({} as Record<OrganizationId, OrgKey>));
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("No org key found");
|
||||
});
|
||||
|
||||
it("should throw an error if orgKeys$ returns null", async () => {
|
||||
// Arrange
|
||||
keyService.orgKeys$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to reset the user's master password", async () => {
|
||||
// Act
|
||||
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1);
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicKeys", () => {
|
||||
it("should return public keys for organizations that have reset password enrolled", async () => {
|
||||
const result = await sut.getPublicKeys("userId" as UserId);
|
||||
|
||||
@@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
|
||||
? new PBKDF2KdfConfig(response.kdfIterations)
|
||||
: new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism);
|
||||
|
||||
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
|
||||
);
|
||||
|
||||
if (newApisWithInputPasswordFlagEnabled) {
|
||||
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
|
||||
|
||||
// Create authentication and unlock data
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
existingUserKey,
|
||||
);
|
||||
|
||||
// Create request
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
|
||||
// Change user's password
|
||||
await this.organizationUserApiService.putOrganizationUserResetPassword(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
|
||||
return; // EARLY RETURN for flagged code
|
||||
}
|
||||
|
||||
// Create new master key and hash new password
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
newMasterPassword,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Constructor } from "type-fest";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -56,7 +56,7 @@ export abstract class BasePolicyEditDefinition {
|
||||
* If true, the {@link description} will be reused in the policy edit modal. Set this to false if you
|
||||
* have more complex requirements that you will implement in your template instead.
|
||||
**/
|
||||
showDescription: boolean = true;
|
||||
showDescription: boolean = false;
|
||||
|
||||
/**
|
||||
* A method that determines whether to display this policy in the Admin Console Policies page.
|
||||
@@ -80,7 +80,7 @@ export abstract class BasePolicyEditDefinition {
|
||||
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;
|
||||
@Input() policyResponse: PolicyStatusResponse | undefined;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() policy: BasePolicyEditDefinition | undefined;
|
||||
|
||||
@@ -2,6 +2,7 @@ export { PoliciesComponent } from "./policies.component";
|
||||
export { ossPolicyEditRegister } from "./policy-edit-register";
|
||||
export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
|
||||
export { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component";
|
||||
export { AutoConfirmPolicy } from "./policy-edit-definitions";
|
||||
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
|
||||
export * from "./policy-edit-dialogs";
|
||||
export { PolicyOrderPipe } from "./pipes/policy-order.pipe";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { BasePolicyEditDefinition } from "../base-policy-edit.component";
|
||||
|
||||
/**
|
||||
* Order mapping for policies. Policies are ordered according to this mapping.
|
||||
* Policies not in this mapping will appear at the end, maintaining their relative order.
|
||||
*/
|
||||
const POLICY_ORDER_MAP = new Map<string, number>([
|
||||
["singleOrg", 1],
|
||||
["organizationDataOwnership", 2],
|
||||
["centralizeDataOwnership", 2],
|
||||
["masterPassPolicyTitle", 3],
|
||||
["accountRecoveryPolicy", 4],
|
||||
["requireSso", 5],
|
||||
["automaticAppLoginWithSSO", 6],
|
||||
["twoStepLoginPolicyTitle", 7],
|
||||
["blockClaimedDomainAccountCreation", 8],
|
||||
["sessionTimeoutPolicyTitle", 9],
|
||||
["removeUnlockWithPinPolicyTitle", 10],
|
||||
["passwordGenerator", 11],
|
||||
["uriMatchDetectionPolicy", 12],
|
||||
["activateAutofillPolicy", 13],
|
||||
["sendOptions", 14],
|
||||
["disableSend", 15],
|
||||
["restrictedItemTypePolicy", 16],
|
||||
["freeFamiliesSponsorship", 17],
|
||||
["disableExport", 18],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Default order for policies not in the mapping. This ensures unmapped policies
|
||||
* appear at the end while maintaining their relative order.
|
||||
*/
|
||||
const DEFAULT_ORDER = 999;
|
||||
|
||||
@Pipe({
|
||||
name: "policyOrder",
|
||||
standalone: true,
|
||||
})
|
||||
export class PolicyOrderPipe implements PipeTransform {
|
||||
transform(
|
||||
policies: readonly BasePolicyEditDefinition[] | null | undefined,
|
||||
): BasePolicyEditDefinition[] {
|
||||
if (policies == null || policies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedPolicies = [...policies];
|
||||
|
||||
sortedPolicies.sort((a, b) => {
|
||||
const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER;
|
||||
const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER;
|
||||
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
const indexA = policies.indexOf(a);
|
||||
const indexB = policies.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
return sortedPolicies;
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,18 @@
|
||||
} @else {
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
@for (p of policies$ | async; track $index) {
|
||||
@for (p of policies$ | async | policyOrder; track $index) {
|
||||
@if (p.display$(organization, configService) | async) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
<button type="button" bitLink (click)="edit(p, organizationId)">
|
||||
{{ p.name | i18n }}
|
||||
</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<button type="button" bitLink (click)="edit(p, organizationId)">
|
||||
{{ p.name | i18n }}
|
||||
</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</div>
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
|
||||
@@ -14,20 +14,21 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
|
||||
import { PolicyOrderPipe } from "./pipes/policy-order.pipe";
|
||||
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
|
||||
import { PolicyListService } from "./policy-list.service";
|
||||
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
|
||||
@Component({
|
||||
templateUrl: "policies.component.html",
|
||||
imports: [SharedModule, HeaderModule],
|
||||
imports: [SharedModule, HeaderModule, PolicyOrderPipe],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: PolicyListService,
|
||||
@@ -36,7 +37,8 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PoliciesComponent {
|
||||
export class PoliciesComponent implements OnDestroy {
|
||||
private myDialogRef?: DialogRef;
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
protected organizationId$: Observable<OrganizationId> = this.route.params.pipe(
|
||||
@@ -97,6 +99,10 @@ export class PoliciesComponent {
|
||||
this.handleLaunchEvent();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.myDialogRef?.close();
|
||||
}
|
||||
|
||||
// Handle policies component launch from Event message
|
||||
private handleLaunchEvent() {
|
||||
combineLatest([
|
||||
@@ -130,7 +136,7 @@ export class PoliciesComponent {
|
||||
edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) {
|
||||
const dialogComponent: PolicyDialogComponent =
|
||||
policy.editDialogComponent ?? PolicyEditDialogComponent;
|
||||
dialogComponent.open(this.dialogService, {
|
||||
this.myDialogRef = dialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
policy: policy,
|
||||
organizationId: organizationId,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<ng-template #step1>
|
||||
<div class="tw-flex tw-justify-center tw-mb-6">
|
||||
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
|
||||
<bit-svg class="tw-w-[233px]" [content]="autoConfirmSvg"></bit-svg>
|
||||
</div>
|
||||
<ol>
|
||||
<li>1. {{ "autoConfirmExtension1" | i18n }}</li>
|
||||
|
||||
@@ -15,8 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
|
||||
import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component";
|
||||
|
||||
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
|
||||
name = "autoConfirm";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export { DisableSendPolicy } from "./disable-send.component";
|
||||
export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component";
|
||||
export { MasterPasswordPolicy } from "./master-password.component";
|
||||
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
|
||||
export {
|
||||
OrganizationDataOwnershipPolicy,
|
||||
OrganizationDataOwnershipPolicyComponent,
|
||||
} from "./organization-data-ownership.component";
|
||||
export { PasswordGeneratorPolicy } from "./password-generator.component";
|
||||
export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component";
|
||||
export { RequireSsoPolicy } from "./require-sso.component";
|
||||
|
||||
@@ -13,28 +13,24 @@
|
||||
<bit-label>{{ "enforceOnLoginDesc" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<div class="tw-flex tw-space-x-4">
|
||||
<bit-form-field class="tw-flex-auto">
|
||||
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
|
||||
<bit-select formControlName="minComplexity" id="minComplexity">
|
||||
<bit-option
|
||||
*ngFor="let o of passwordScores"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-flex-auto">
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minLength"
|
||||
id="minLength"
|
||||
[min]="MinPasswordLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
|
||||
<bit-select formControlName="minComplexity" id="minComplexity">
|
||||
<bit-option *ngFor="let o of passwordScores" [value]="o.value" [label]="o.name"></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minLength"
|
||||
id="minLength"
|
||||
[min]="MinPasswordLength"
|
||||
[max]="MaxPasswordLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-control class="!tw-mb-2">
|
||||
<input type="checkbox" bitCheckbox formControlName="requireUpper" id="requireUpper" />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { MasterPasswordPolicyComponent } from "./master-password.component";
|
||||
|
||||
describe("MasterPasswordPolicyComponent", () => {
|
||||
let component: MasterPasswordPolicyComponent;
|
||||
let fixture: ComponentFixture<MasterPasswordPolicyComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
|
||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MasterPasswordPolicyComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should accept minimum password length of 12", () => {
|
||||
component.data.patchValue({ minLength: 12 });
|
||||
|
||||
expect(component.data.get("minLength")?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept maximum password length of 128", () => {
|
||||
component.data.patchValue({ minLength: 128 });
|
||||
|
||||
expect(component.data.get("minLength")?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject password length below minimum", () => {
|
||||
component.data.patchValue({ minLength: 11 });
|
||||
|
||||
expect(component.data.get("minLength")?.hasError("min")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject password length above maximum", () => {
|
||||
component.data.patchValue({ minLength: 129 });
|
||||
|
||||
expect(component.data.get("minLength")?.hasError("max")).toBe(true);
|
||||
});
|
||||
|
||||
it("should use correct minimum from Utils", () => {
|
||||
expect(component.MinPasswordLength).toBe(Utils.minimumPasswordLength);
|
||||
expect(component.MinPasswordLength).toBe(12);
|
||||
});
|
||||
|
||||
it("should use correct maximum from Utils", () => {
|
||||
expect(component.MaxPasswordLength).toBe(Utils.maximumPasswordLength);
|
||||
expect(component.MaxPasswordLength).toBe(128);
|
||||
});
|
||||
|
||||
it("should have password scores from 0 to 4", () => {
|
||||
const scores = component.passwordScores.filter((s) => s.value !== null).map((s) => s.value);
|
||||
|
||||
expect(scores).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,14 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
|
||||
})
|
||||
export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
|
||||
MinPasswordLength = Utils.minimumPasswordLength;
|
||||
MaxPasswordLength = Utils.maximumPasswordLength;
|
||||
|
||||
data: FormGroup<ControlsOf<MasterPasswordPolicyOptions>> = this.formBuilder.group({
|
||||
minComplexity: [null],
|
||||
minLength: [this.MinPasswordLength, [Validators.min(Utils.minimumPasswordLength)]],
|
||||
minLength: [
|
||||
this.MinPasswordLength,
|
||||
[Validators.min(Utils.minimumPasswordLength), Validators.max(this.MaxPasswordLength)],
|
||||
],
|
||||
requireUpper: [false],
|
||||
requireLower: [false],
|
||||
requireNumbers: [false],
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
<bit-callout type="warning">
|
||||
{{ "personalOwnershipExemption" | i18n }}
|
||||
</bit-callout>
|
||||
<p>
|
||||
{{ "organizationDataOwnershipDescContent" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<ng-template #dialog>
|
||||
<bit-simple-dialog background="alt">
|
||||
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<div class="tw-text-left tw-overflow-hidden">
|
||||
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning1" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning2" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning3" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<span class="tw-flex tw-gap-2">
|
||||
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { of, Observable } from "rxjs";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { lastValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
|
||||
import { EncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
|
||||
|
||||
export interface VNextPolicyRequest {
|
||||
policy: PolicyRequest;
|
||||
metadata: {
|
||||
defaultUserCollectionName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
name = "organizationDataOwnership";
|
||||
description = "personalOwnershipPolicyDesc";
|
||||
description = "organizationDataOwnershipDesc";
|
||||
type = PolicyType.OrganizationDataOwnership;
|
||||
component = OrganizationDataOwnershipPolicyComponent;
|
||||
showDescription = false;
|
||||
|
||||
display$(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
// TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
|
||||
return of(false);
|
||||
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return configService
|
||||
.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems)
|
||||
.pipe(map((enabled) => !enabled));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +42,61 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}
|
||||
export class OrganizationDataOwnershipPolicyComponent
|
||||
extends BasePolicyEditComponent
|
||||
implements OnInit
|
||||
{
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private encryptService: EncryptService,
|
||||
) {
|
||||
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> {
|
||||
if (this.policyResponse?.enabled && !this.enabled.value) {
|
||||
const dialogRef = this.dialogService.open(this.warningContent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
return Boolean(result);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
}
|
||||
|
||||
const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
|
||||
|
||||
const request: VNextPolicyRequest = {
|
||||
policy: {
|
||||
enabled: this.enabled.value ?? false,
|
||||
data: this.buildRequestData(),
|
||||
},
|
||||
metadata: {
|
||||
defaultUserCollectionName,
|
||||
},
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private async getEncryptedDefaultUserCollectionName(orgKey: OrgKey): Promise<EncString> {
|
||||
const defaultCollectionName = this.i18nService.t("myItems");
|
||||
const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey);
|
||||
|
||||
if (!encrypted.encryptedString) {
|
||||
throw new Error("Encryption error");
|
||||
}
|
||||
|
||||
return encrypted.encryptedString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,56 +4,50 @@
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6 tw-mb-0">
|
||||
<bit-label>{{ "overridePasswordTypePolicy" | i18n }}</bit-label>
|
||||
<bit-select formControlName="overridePasswordType" id="overrideType">
|
||||
<bit-option
|
||||
*ngFor="let o of overridePasswordTypeOptions"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "passwordTypePolicyOverride" | i18n }}</bit-label>
|
||||
<bit-select formControlName="overridePasswordType" id="overrideType">
|
||||
<bit-option
|
||||
*ngFor="let o of overridePasswordTypeOptions"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<!-- password-specific policies -->
|
||||
<div *ngIf="showPasswordPolicies$ | async">
|
||||
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minLengthMin"
|
||||
[max]="minLengthMax"
|
||||
formControlName="minLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumbersMin"
|
||||
[max]="minNumbersMax"
|
||||
formControlName="minNumbers"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minSpecialMin"
|
||||
[max]="minSpecialMax"
|
||||
formControlName="minSpecial"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minLengthMin"
|
||||
[max]="minLengthMax"
|
||||
formControlName="minLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumbersMin"
|
||||
[max]="minNumbersMax"
|
||||
formControlName="minNumbers"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minSpecialMin"
|
||||
[max]="minSpecialMax"
|
||||
formControlName="minSpecial"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
|
||||
<bit-label>{{ "uppercaseLabel" | i18n }}</bit-label>
|
||||
@@ -79,18 +73,16 @@
|
||||
<!-- passphrase-specific policies -->
|
||||
<div *ngIf="showPassphrasePolicies$ | async">
|
||||
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumberWordsMin"
|
||||
[max]="minNumberWordsMax"
|
||||
formControlName="minNumberWords"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumberWordsMin"
|
||||
[max]="minNumberWordsMax"
|
||||
formControlName="minNumberWords"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
|
||||
<bit-label>{{ "capitalize" | i18n }}</bit-label>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import {
|
||||
@@ -42,8 +42,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("input selected on load when policy enabled", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
@@ -63,8 +62,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("input not selected on load when policy disabled", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: false,
|
||||
@@ -84,8 +82,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("turn on message label", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: false,
|
||||
|
||||
@@ -1,57 +1,52 @@
|
||||
<p>
|
||||
{{ "organizationDataOwnershipDescContent" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</p>
|
||||
<ng-container [ngTemplateOutlet]="steps[step()]()"></ng-container>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
<ng-template #step0>
|
||||
<p>
|
||||
{{ "centralizeDataOwnershipDesc" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "centralizeDataOwnershipContentAnchor" | i18n }}
|
||||
<i class="bwi bwi-external-link"></i>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<ng-template #dialog>
|
||||
<bit-simple-dialog background="alt">
|
||||
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<div class="tw-text-left tw-overflow-hidden">
|
||||
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning1" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning2" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning3" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<span class="tw-flex tw-gap-2">
|
||||
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
<div class="tw-text-left tw-overflow-hidden tw-mb-2">
|
||||
<strong>{{ "benefits" | i18n }}:</strong>
|
||||
<ul class="tw-pl-7 tw-space-y-2 tw-pt-2">
|
||||
<li>
|
||||
{{ "centralizeDataOwnershipBenefit1" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "centralizeDataOwnershipBenefit2" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "centralizeDataOwnershipBenefit3" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<bit-form-control>
|
||||
<input class="tw-mt-4" type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #step1>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2 tw-overflow-hidden">
|
||||
<span>
|
||||
{{ "centralizeDataOwnershipWarningDesc" | i18n }}
|
||||
</span>
|
||||
<a
|
||||
class="tw-mt-4"
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "centralizeDataOwnershipWarningLink" | i18n }}
|
||||
<i class="bwi bwi-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
signal,
|
||||
Signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
WritableSignal,
|
||||
} from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
|
||||
import { EncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
|
||||
import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
|
||||
|
||||
interface VNextPolicyRequest {
|
||||
export interface VNextPolicyRequest {
|
||||
policy: PolicyRequest;
|
||||
metadata: {
|
||||
defaultUserCollectionName: string;
|
||||
@@ -20,11 +32,17 @@ interface VNextPolicyRequest {
|
||||
}
|
||||
|
||||
export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
name = "organizationDataOwnership";
|
||||
description = "organizationDataOwnershipDesc";
|
||||
name = "centralizeDataOwnership";
|
||||
description = "centralizeDataOwnershipDesc";
|
||||
type = PolicyType.OrganizationDataOwnership;
|
||||
component = vNextOrganizationDataOwnershipPolicyComponent;
|
||||
showDescription = false;
|
||||
|
||||
editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent;
|
||||
|
||||
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -38,27 +56,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent
|
||||
implements OnInit
|
||||
{
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private encryptService: EncryptService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
|
||||
private readonly warningContent: Signal<TemplateRef<any> | undefined> = viewChild("step1");
|
||||
protected readonly step: WritableSignal<number> = signal(0);
|
||||
|
||||
// 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> {
|
||||
if (this.policyResponse?.enabled && !this.enabled.value) {
|
||||
const dialogRef = this.dialogService.open(this.warningContent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
return Boolean(result);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected steps = [this.policyForm, this.warningContent];
|
||||
|
||||
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
|
||||
if (!this.policy) {
|
||||
@@ -90,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
|
||||
|
||||
return encrypted.encryptedString;
|
||||
}
|
||||
|
||||
setStep(step: number) {
|
||||
this.step.set(step);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="default" [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ policy.name | i18n }}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
@@ -28,7 +29,7 @@ import { KeyService } from "@bitwarden/key-management";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
|
||||
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
|
||||
import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component";
|
||||
|
||||
export type PolicyEditDialogData = {
|
||||
/**
|
||||
@@ -73,13 +74,28 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
private formBuilder: FormBuilder,
|
||||
protected dialogRef: DialogRef<PolicyEditDialogResult>,
|
||||
protected toastService: ToastService,
|
||||
private keyService: KeyService,
|
||||
protected keyService: KeyService,
|
||||
) {}
|
||||
|
||||
get policy(): BasePolicyEditDefinition {
|
||||
return this.data.policy;
|
||||
}
|
||||
|
||||
get isPolicyEnabled(): boolean {
|
||||
return this.policyComponent?.policyResponse?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the policy component has the buildVNextRequest method.
|
||||
*/
|
||||
private hasVNextRequest(
|
||||
component: BasePolicyEditComponent,
|
||||
): component is BasePolicyEditComponent & {
|
||||
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
|
||||
} {
|
||||
return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates the child policy component and inserts it into the view.
|
||||
*/
|
||||
@@ -129,7 +145,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
|
||||
if (this.hasVNextRequest(this.policyComponent)) {
|
||||
await this.handleVNextSubmission(this.policyComponent);
|
||||
} else {
|
||||
await this.handleStandardSubmission();
|
||||
@@ -158,7 +174,9 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
private async handleVNextSubmission(
|
||||
policyComponent: vNextOrganizationDataOwnershipPolicyComponent,
|
||||
policyComponent: BasePolicyEditComponent & {
|
||||
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const orgKey = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
@@ -173,15 +191,18 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
throw new Error("No encryption key for this organization.");
|
||||
}
|
||||
|
||||
const vNextRequest = await policyComponent.buildVNextRequest(orgKey);
|
||||
const request = await policyComponent.buildVNextRequest(orgKey);
|
||||
|
||||
await this.policyApiService.putPolicyVNext(
|
||||
this.data.organizationId,
|
||||
this.data.policy.type,
|
||||
vNextRequest,
|
||||
request,
|
||||
);
|
||||
}
|
||||
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
||||
return dialogService.open<PolicyEditDialogResult>(PolicyEditDialogComponent, config);
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
|
||||
PolicyEditDialogComponent,
|
||||
config,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [loading]="loading">
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
|
||||
@if (title) {
|
||||
@@ -40,13 +40,16 @@
|
||||
@if (showBadge) {
|
||||
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
|
||||
}
|
||||
<span>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }}
|
||||
@if (!showBadge) {
|
||||
<span class="tw-text-muted tw-font-normal tw-text-sm">
|
||||
{{ policy.name | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,270 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AutoConfirmPolicyDialogComponent,
|
||||
AutoConfirmPolicyDialogData,
|
||||
} from "./auto-confirm-edit-policy-dialog.component";
|
||||
|
||||
describe("AutoConfirmPolicyDialogComponent", () => {
|
||||
let component: AutoConfirmPolicyDialogComponent;
|
||||
let fixture: ComponentFixture<AutoConfirmPolicyDialogComponent>;
|
||||
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockAutoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
let mockDialogRef: MockProxy<DialogRef>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
|
||||
const mockDialogData: AutoConfirmPolicyDialogData = {
|
||||
organizationId: mockOrgId,
|
||||
policy: {
|
||||
name: "autoConfirm",
|
||||
description: "Auto Confirm Policy",
|
||||
type: PolicyType.AutoConfirm,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
},
|
||||
firstTimeDialog: false,
|
||||
};
|
||||
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
isAdmin: true,
|
||||
canManagePolicies: true,
|
||||
} as Organization;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockAutoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
mockDialogRef = mock<DialogRef>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockKeyService = mock<KeyService>();
|
||||
|
||||
mockPolicyService.policies$.mockReturnValue(of([]));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutoConfirmPolicyDialogComponent],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: DIALOG_DATA, useValue: mockDialogData },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(AutoConfirmPolicyDialogComponent, {
|
||||
set: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("handleSubmit", () => {
|
||||
beforeEach(() => {
|
||||
// Mock the policyComponent
|
||||
component.policyComponent = {
|
||||
buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }),
|
||||
enabled: { value: true },
|
||||
setSingleOrgEnabled: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockAutoConfirmService.configuration$.mockReturnValue(
|
||||
of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }),
|
||||
);
|
||||
mockAutoConfirmService.upsert.mockResolvedValue(undefined);
|
||||
mockI18nService.t.mockReturnValue("Policy updated");
|
||||
});
|
||||
|
||||
it("should enable SingleOrg policy when it was not already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled)
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// First call should be SingleOrg enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not enable SingleOrg policy when it was already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled)
|
||||
await component["handleSubmit"](true);
|
||||
|
||||
// Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls)
|
||||
mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError);
|
||||
|
||||
await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify only AutoConfirm was called (no SingleOrg enable/rollback)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep both policies enabled when both submissions succeed", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// Verify two calls: SingleOrg enable and AutoConfirm enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should re-throw the error after rollback", async () => {
|
||||
const autoConfirmError = new Error("Network error");
|
||||
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSingleOrgPolicy", () => {
|
||||
it("should call putPolicyVNext with enabled: true when enabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](true);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call putPolicyVNext with enabled: false when disabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](false);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,20 +41,15 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
|
||||
import {
|
||||
PolicyEditDialogComponent,
|
||||
PolicyEditDialogData,
|
||||
PolicyEditDialogResult,
|
||||
} from "./policy-edit-dialog.component";
|
||||
} from "../policy-edit-dialog.component";
|
||||
|
||||
export type MultiStepSubmit = {
|
||||
sideEffect: () => Promise<void>;
|
||||
footerContent: Signal<TemplateRef<unknown> | undefined>;
|
||||
titleContent: Signal<TemplateRef<unknown> | undefined>;
|
||||
};
|
||||
import { MultiStepSubmit } from "./models";
|
||||
|
||||
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
|
||||
firstTimeDialog?: boolean;
|
||||
@@ -186,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
|
||||
private async handleSubmit(singleOrgEnabled: boolean) {
|
||||
if (!singleOrgEnabled) {
|
||||
await this.submitSingleOrg();
|
||||
const enabledSingleOrgDuringAction = !singleOrgEnabled;
|
||||
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.submitAutoConfirm();
|
||||
} catch (error) {
|
||||
// Roll back SingleOrg if we enabled it during this action
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.submitAutoConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,11 +208,10 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
|
||||
const autoConfirmRequest = await this.policyComponent.buildRequest();
|
||||
await this.policyApiService.putPolicy(
|
||||
this.data.organizationId,
|
||||
this.data.policy.type,
|
||||
autoConfirmRequest,
|
||||
);
|
||||
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, {
|
||||
policy: autoConfirmRequest,
|
||||
});
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
@@ -229,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
}
|
||||
|
||||
private async submitSingleOrg(): Promise<void> {
|
||||
private async setSingleOrgPolicy(enabled: boolean): Promise<void> {
|
||||
const singleOrgRequest: PolicyRequest = {
|
||||
enabled: true,
|
||||
enabled,
|
||||
data: null,
|
||||
};
|
||||
|
||||
await this.policyApiService.putPolicy(
|
||||
this.data.organizationId,
|
||||
PolicyType.SingleOrg,
|
||||
singleOrgRequest,
|
||||
);
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, {
|
||||
policy: singleOrgRequest,
|
||||
});
|
||||
}
|
||||
|
||||
private async openBrowserExtension() {
|
||||
@@ -260,7 +263,10 @@ export class AutoConfirmPolicyDialogComponent
|
||||
|
||||
try {
|
||||
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
|
||||
await multiStepSubmit[this.currentStep()].sideEffect();
|
||||
const sideEffect = multiStepSubmit[this.currentStep()].sideEffect;
|
||||
if (sideEffect) {
|
||||
await sideEffect();
|
||||
}
|
||||
|
||||
if (this.currentStep() === multiStepSubmit.length - 1) {
|
||||
this.dialogRef.close("saved");
|
||||
@@ -281,6 +287,9 @@ export class AutoConfirmPolicyDialogComponent
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<AutoConfirmPolicyDialogData>,
|
||||
) => {
|
||||
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, AutoConfirmPolicyDialogData>(
|
||||
AutoConfirmPolicyDialogComponent,
|
||||
config,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auto-confirm-edit-policy-dialog.component";
|
||||
export * from "./organization-data-ownership-edit-policy-dialog.component";
|
||||
export * from "./models";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Signal, TemplateRef } from "@angular/core";
|
||||
|
||||
export type MultiStepSubmit = {
|
||||
sideEffect?: () => Promise<void>;
|
||||
footerContent: Signal<TemplateRef<unknown> | undefined>;
|
||||
titleContent: Signal<TemplateRef<unknown> | undefined>;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
@let title = multiStepSubmit()[currentStep()]?.titleContent();
|
||||
@if (title) {
|
||||
<ng-container [ngTemplateOutlet]="title"></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container bitDialogContent>
|
||||
@if (loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
}
|
||||
<div [hidden]="loading">
|
||||
@if (policy.showDescription) {
|
||||
<p bitTypography="body1">{{ policy.description | i18n }}</p>
|
||||
}
|
||||
</div>
|
||||
<ng-template #policyForm></ng-template>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
@let footer = multiStepSubmit()[currentStep()]?.footerContent();
|
||||
@if (footer) {
|
||||
<ng-container [ngTemplateOutlet]="footer"></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
|
||||
<ng-template #step0Title>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ policy.name | i18n }}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #step1Title>
|
||||
{{ "centralizeDataOwnershipWarningTitle" | i18n }}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #step0>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="saveDisabled$ | async"
|
||||
bitFormButton
|
||||
type="submit"
|
||||
>
|
||||
@if (policyComponent?.policyResponse?.enabled) {
|
||||
{{ "save" | i18n }}
|
||||
} @else {
|
||||
{{ "continue" | i18n }}
|
||||
}
|
||||
</button>
|
||||
|
||||
<button bitButton buttonType="secondary" bitDialogClose type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #step1>
|
||||
<button bitButton buttonType="primary" bitFormButton type="submit">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" bitDialogClose type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
WritableSignal,
|
||||
} from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
defer,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { vNextOrganizationDataOwnershipPolicyComponent } from "../policy-edit-definitions";
|
||||
import {
|
||||
PolicyEditDialogComponent,
|
||||
PolicyEditDialogData,
|
||||
PolicyEditDialogResult,
|
||||
} from "../policy-edit-dialog.component";
|
||||
|
||||
import { MultiStepSubmit } from "./models";
|
||||
|
||||
/**
|
||||
* Custom policy dialog component for Centralize Organization Data
|
||||
* Ownership policy. Satisfies the PolicyDialogComponent interface
|
||||
* structurally via its static open() function.
|
||||
*/
|
||||
// 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-edit-policy-dialog.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class OrganizationDataOwnershipPolicyDialogComponent
|
||||
extends PolicyEditDialogComponent
|
||||
implements AfterViewInit
|
||||
{
|
||||
policyType = PolicyType;
|
||||
|
||||
protected centralizeDataOwnershipEnabled$: Observable<boolean> = defer(() =>
|
||||
from(
|
||||
this.policyApiService.getPolicy(
|
||||
this.data.organizationId,
|
||||
PolicyType.OrganizationDataOwnership,
|
||||
),
|
||||
).pipe(
|
||||
map((policy) => policy.enabled),
|
||||
catchError(() => of(false)),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly currentStep: WritableSignal<number> = signal(0);
|
||||
protected readonly multiStepSubmit: WritableSignal<MultiStepSubmit[]> = signal([]);
|
||||
|
||||
private readonly policyForm = viewChild.required<TemplateRef<unknown>>("step0");
|
||||
private readonly warningContent = viewChild.required<TemplateRef<unknown>>("step1");
|
||||
private readonly policyFormTitle = viewChild.required<TemplateRef<unknown>>("step0Title");
|
||||
private readonly warningTitle = viewChild.required<TemplateRef<unknown>>("step1Title");
|
||||
|
||||
override policyComponent: vNextOrganizationDataOwnershipPolicyComponent | undefined;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
|
||||
accountService: AccountService,
|
||||
policyApiService: PolicyApiServiceAbstraction,
|
||||
i18nService: I18nService,
|
||||
cdr: ChangeDetectorRef,
|
||||
formBuilder: FormBuilder,
|
||||
dialogRef: DialogRef<PolicyEditDialogResult>,
|
||||
toastService: ToastService,
|
||||
protected keyService: KeyService,
|
||||
) {
|
||||
super(
|
||||
data,
|
||||
accountService,
|
||||
policyApiService,
|
||||
i18nService,
|
||||
cdr,
|
||||
formBuilder,
|
||||
dialogRef,
|
||||
toastService,
|
||||
keyService,
|
||||
);
|
||||
}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
await super.ngAfterViewInit();
|
||||
|
||||
if (this.policyComponent) {
|
||||
this.saveDisabled$ = combineLatest([
|
||||
this.centralizeDataOwnershipEnabled$,
|
||||
this.policyComponent.enabled.valueChanges.pipe(
|
||||
startWith(this.policyComponent.enabled.value),
|
||||
),
|
||||
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
|
||||
}
|
||||
|
||||
this.multiStepSubmit.set(this.buildMultiStepSubmit());
|
||||
}
|
||||
|
||||
private buildMultiStepSubmit(): MultiStepSubmit[] {
|
||||
if (this.policyComponent?.policyResponse?.enabled) {
|
||||
return [
|
||||
{
|
||||
sideEffect: () => this.handleSubmit(),
|
||||
footerContent: this.policyForm,
|
||||
titleContent: this.policyFormTitle,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
footerContent: this.policyForm,
|
||||
titleContent: this.policyFormTitle,
|
||||
},
|
||||
{
|
||||
sideEffect: () => this.handleSubmit(),
|
||||
footerContent: this.warningContent,
|
||||
titleContent: this.warningTitle,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async handleSubmit() {
|
||||
if (!this.policyComponent) {
|
||||
throw new Error("PolicyComponent not initialized.");
|
||||
}
|
||||
|
||||
const orgKey = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
assertNonNullish(orgKey, "Org key not provided");
|
||||
|
||||
const request = await this.policyComponent.buildVNextRequest(
|
||||
orgKey[this.data.organizationId as OrganizationId],
|
||||
);
|
||||
|
||||
await this.policyApiService.putPolicyVNext(
|
||||
this.data.organizationId,
|
||||
this.data.policy.type,
|
||||
request,
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
|
||||
});
|
||||
|
||||
if (!this.policyComponent.enabled.value) {
|
||||
this.dialogRef.close("saved");
|
||||
}
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (!this.policyComponent) {
|
||||
throw new Error("PolicyComponent not initialized.");
|
||||
}
|
||||
|
||||
if ((await this.policyComponent.confirm()) == false) {
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sideEffect = this.multiStepSubmit()[this.currentStep()].sideEffect;
|
||||
if (sideEffect) {
|
||||
await sideEffect();
|
||||
}
|
||||
|
||||
if (this.currentStep() === this.multiStepSubmit().length - 1) {
|
||||
this.dialogRef.close("saved");
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStep.update((value) => value + 1);
|
||||
this.policyComponent.setStep(this.currentStep());
|
||||
} catch (error: any) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
|
||||
OrganizationDataOwnershipPolicyDialogComponent,
|
||||
config,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { SelectItemView } from "@bitwarden/components";
|
||||
|
||||
import { GroupView } from "../../../core";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<bit-dialog [disablePadding]="!loading" dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
<ng-container *ngIf="editMode">
|
||||
{{ "editCollection" | i18n }}
|
||||
{{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }}
|
||||
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
|
||||
collection.name
|
||||
}}</span>
|
||||
@@ -63,7 +63,7 @@
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
<bit-tab label="{{ 'access' | i18n }}">
|
||||
<bit-tab [label]="accessTabLabel">
|
||||
<div class="tw-mb-3">
|
||||
<ng-container *ngIf="dialogReadonly">
|
||||
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>
|
||||
|
||||
@@ -18,19 +18,21 @@ import {
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserUserMiniResponse,
|
||||
CollectionResponse,
|
||||
CollectionView,
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
CollectionAccessSelectionView,
|
||||
CollectionAdminView,
|
||||
CollectionView,
|
||||
CollectionResponse,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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";
|
||||
@@ -359,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
return this.params.readonly === true;
|
||||
}
|
||||
|
||||
protected get accessTabLabel(): string {
|
||||
return this.dialogReadonly
|
||||
? this.i18nService.t("viewAccess")
|
||||
: this.i18nService.t("editAccess");
|
||||
}
|
||||
|
||||
protected async cancel() {
|
||||
this.close(CollectionDialogAction.Canceled);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
|
||||
import { combineLatest, map, Observable, of } from "rxjs";
|
||||
|
||||
import { Collection } from "@bitwarden/admin-console/common";
|
||||
import { Collection } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="tw-mt-10 tw-flex tw-justify-center" *ngIf="loading">
|
||||
<div>
|
||||
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n">
|
||||
</bit-icon>
|
||||
<bit-svg class="tw-w-72 tw-block tw-mb-4" [content]="logo" [ariaLabel]="'appLogoLabel' | i18n">
|
||||
</bit-svg>
|
||||
<div class="tw-flex tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BitwardenLogo } from "@bitwarden/assets/svg";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { IconModule, ToastService } from "@bitwarden/components";
|
||||
import { SvgModule, ToastService } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BaseAcceptComponent } from "../../../common/base.accept.component";
|
||||
@@ -22,7 +22,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "accept-family-sponsorship.component.html",
|
||||
imports: [CommonModule, I18nPipe, IconModule],
|
||||
imports: [CommonModule, I18nPipe, SvgModule],
|
||||
})
|
||||
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
|
||||
protected logo = BitwardenLogo;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
InitializeJitPasswordCredentials,
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordUserType,
|
||||
@@ -20,11 +21,13 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
@@ -47,6 +50,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
let organizationInviteService: MockProxy<OrganizationInviteService>;
|
||||
let routerService: MockProxy<RouterService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
let registerSdkService: MockProxy<RegisterSdkService>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
@@ -62,6 +66,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
organizationInviteService = mock<OrganizationInviteService>();
|
||||
routerService = mock<RouterService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
registerSdkService = mock<RegisterSdkService>();
|
||||
|
||||
sut = new WebSetInitialPasswordService(
|
||||
apiService,
|
||||
@@ -77,6 +82,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
organizationInviteService,
|
||||
routerService,
|
||||
accountCryptographicStateService,
|
||||
registerSdkService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,6 +90,10 @@ describe("WebSetInitialPasswordService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
|
||||
* in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
@@ -113,6 +123,8 @@ describe("WebSetInitialPasswordService", () => {
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId",
|
||||
resetPasswordAutoEnroll: false,
|
||||
newPassword: "Test@Password123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
};
|
||||
userId = "userId" as UserId;
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
@@ -208,4 +220,36 @@ describe("WebSetInitialPasswordService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
|
||||
it("should call routerService.getAndClearLoginRedirectUrl() and organizationInviteService.clearOrganizationInvitation()", async () => {
|
||||
// Arrange
|
||||
const credentials: InitializeJitPasswordCredentials = {
|
||||
newPasswordHint: "newPasswordHint",
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId" as OrganizationId,
|
||||
resetPasswordAutoEnroll: false,
|
||||
newPassword: "newPassword123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
};
|
||||
const userId = "userId" as UserId;
|
||||
|
||||
const superSpy = jest
|
||||
.spyOn(
|
||||
Object.getPrototypeOf(Object.getPrototypeOf(sut)),
|
||||
"initializePasswordJitPasswordUserV2Encryption",
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
|
||||
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
|
||||
|
||||
superSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
|
||||
import {
|
||||
InitializeJitPasswordCredentials,
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordUserType,
|
||||
@@ -14,6 +15,7 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
@@ -36,6 +38,7 @@ export class WebSetInitialPasswordService
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
private routerService: RouterService,
|
||||
protected accountCryptographicStateService: AccountCryptographicStateService,
|
||||
protected registerSdkService: RegisterSdkService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
@@ -49,9 +52,13 @@ export class WebSetInitialPasswordService
|
||||
organizationUserApiService,
|
||||
userDecryptionOptionsService,
|
||||
accountCryptographicStateService,
|
||||
registerSdkService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
override async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
@@ -83,4 +90,15 @@ export class WebSetInitialPasswordService
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
|
||||
override async initializePasswordJitPasswordUserV2Encryption(
|
||||
credentials: InitializeJitPasswordCredentials,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
|
||||
|
||||
// TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class EmergencyAccessPasswordRequest {
|
||||
newMasterPasswordHash: string;
|
||||
key: string;
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// The body of this request will be changed to carry the authentication data and unlock data.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructor(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
): EmergencyAccessPasswordRequest {
|
||||
const request = new EmergencyAccessPasswordRequest();
|
||||
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
|
||||
request.key = unlockData.masterKeyWrappedUserKey;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,17 @@ import { of } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
DEFAULT_KDF_CONFIG,
|
||||
KdfType,
|
||||
KeyService,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
@@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => {
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let emergencyAccessService: EmergencyAccessService;
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
|
||||
@@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => {
|
||||
encryptService = mock<EncryptService>();
|
||||
cipherService = mock<CipherService>();
|
||||
logService = mock<LogService>();
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
emergencyAccessService = new EmergencyAccessService(
|
||||
emergencyAccessApiService,
|
||||
@@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => {
|
||||
encryptService,
|
||||
cipherService,
|
||||
logService,
|
||||
masterPasswordService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover", () => {
|
||||
/**
|
||||
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties
|
||||
* in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false;
|
||||
|
||||
const params = {
|
||||
id: "emergencyAccessId",
|
||||
masterPassword: "mockPassword",
|
||||
@@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => {
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
|
||||
);
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
|
||||
|
||||
@@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
|
||||
// Mock feature flag value
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true;
|
||||
|
||||
// Mock sut method params
|
||||
const id = "emergency-access-id";
|
||||
const masterPassword = "mockPassword";
|
||||
const email = "user@example.com";
|
||||
const activeUserId = newGuid() as UserId;
|
||||
|
||||
// Mock method data
|
||||
const kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
|
||||
const takeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: kdfConfig.kdfType,
|
||||
kdfIterations: kdfConfig.iterations,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
let mockGrantorUserKey: UserKey;
|
||||
let salt: MasterPasswordSalt;
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
|
||||
);
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey);
|
||||
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
salt = email as MasterPasswordSalt;
|
||||
masterPasswordService.emailToSalt.mockReturnValue(salt);
|
||||
|
||||
authenticationData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
|
||||
unlockData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
});
|
||||
|
||||
it("should throw if active user private key is not found", async () => {
|
||||
// Arrange
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Active user does not have a private key, cannot complete a takeover.",
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => {
|
||||
// Arrange
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("Failed to decrypt grantor key");
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig, // default config (PBKDF2)
|
||||
salt,
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => {
|
||||
// Arrange
|
||||
const argon2TakeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.Argon2id,
|
||||
kdfIterations: 3,
|
||||
kdfMemory: 64,
|
||||
kdfParallelism: 4,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(
|
||||
argon2TakeoverResponse,
|
||||
);
|
||||
|
||||
const expectedKdfConfig = new Argon2KdfConfig(
|
||||
argon2TakeoverResponse.kdfIterations,
|
||||
argon2TakeoverResponse.kdfMemory,
|
||||
argon2TakeoverResponse.kdfParallelism,
|
||||
);
|
||||
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
expectedKdfConfig,
|
||||
salt,
|
||||
);
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig, // default config (PBKDF2)
|
||||
salt,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
id,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to change the grantor's master password", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
id,
|
||||
request,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const allowedStatuses = [
|
||||
EmergencyAccessStatusType.Confirmed,
|
||||
|
||||
@@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
private encryptService: EncryptService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
* Intended for grantee.
|
||||
* @param id emergency access id
|
||||
* @param masterPassword new master password
|
||||
* @param email email address of grantee (must be consistent or login will fail)
|
||||
* @param email email address of grantor (must be consistent or login will fail)
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
|
||||
@@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
break;
|
||||
}
|
||||
|
||||
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
|
||||
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
|
||||
);
|
||||
|
||||
if (newApisWithInputPasswordFlagEnabled) {
|
||||
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
config,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData: MasterPasswordUnlockData =
|
||||
await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
config,
|
||||
salt,
|
||||
grantorUserKey,
|
||||
);
|
||||
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
|
||||
return; // EARLY RETURN for flagged logic
|
||||
}
|
||||
|
||||
const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config);
|
||||
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);
|
||||
|
||||
|
||||
@@ -166,5 +166,13 @@ describe("EmergencyViewDialogComponent", () => {
|
||||
|
||||
expect(component["title"]).toBe("viewItemHeaderNote");
|
||||
});
|
||||
|
||||
it("sets ssh key title", () => {
|
||||
mockCipher.type = CipherType.SshKey;
|
||||
|
||||
component["updateTitle"]();
|
||||
|
||||
expect(component["title"]).toBe("viewItemHeaderSshKey");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,9 @@ export class EmergencyViewDialogComponent {
|
||||
case CipherType.SecureNote:
|
||||
this.title = this.i18nService.t("viewItemHeaderNote");
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.title = this.i18nService.t("viewItemHeaderSshKey");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
@@ -68,7 +68,7 @@ declare global {
|
||||
TypographyModule,
|
||||
CalloutModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
AsyncActionsModule,
|
||||
JslibModule,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
@@ -42,7 +42,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
|
||||
InputModule,
|
||||
TypographyModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
@@ -45,7 +45,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
InputModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div *ngIf="currentStep === 'credentialCreation'" class="tw-flex tw-flex-col tw-items-center">
|
||||
<div class="tw-size-24 tw-content-center tw-mb-6">
|
||||
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-icon>
|
||||
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-svg>
|
||||
</div>
|
||||
<h3 bitTypography="h3">{{ "creatingPasskeyLoading" | i18n }}</h3>
|
||||
<p bitTypography="body1">{{ "creatingPasskeyLoadingInfo" | i18n }}</p>
|
||||
@@ -27,7 +27,7 @@
|
||||
class="tw-flex tw-flex-col tw-items-center"
|
||||
>
|
||||
<div class="tw-size-24 tw-content-center tw-mb-6">
|
||||
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-icon>
|
||||
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-svg>
|
||||
</div>
|
||||
<h3 bitTypography="h3">{{ "errorCreatingPasskey" | i18n }}</h3>
|
||||
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BitwardenSubscription } from "@bitwarden/subscription";
|
||||
|
||||
import {
|
||||
BillingAddress,
|
||||
@@ -11,13 +15,22 @@ import {
|
||||
@Injectable()
|
||||
export class AccountBillingClient {
|
||||
private endpoint = "/account/billing/vnext";
|
||||
private apiService: ApiService;
|
||||
|
||||
constructor(apiService: ApiService) {
|
||||
this.apiService = apiService;
|
||||
}
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
purchasePremiumSubscription = async (
|
||||
getLicense = async (): Promise<string> => {
|
||||
const path = `${this.endpoint}/license`;
|
||||
return this.apiService.send("GET", path, null, true, true);
|
||||
};
|
||||
|
||||
getSubscription = async (): Promise<BitwardenSubscription> => {
|
||||
const path = `${this.endpoint}/subscription`;
|
||||
const json = await this.apiService.send("GET", path, null, true, true);
|
||||
const response = new BitwardenSubscriptionResponse(json);
|
||||
return response.toDomain();
|
||||
};
|
||||
|
||||
purchaseSubscription = async (
|
||||
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||
): Promise<void> => {
|
||||
@@ -29,6 +42,40 @@ export class AccountBillingClient {
|
||||
const request = isTokenizedPayment
|
||||
? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }
|
||||
: { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress };
|
||||
|
||||
await this.apiService.send("POST", path, request, true, true);
|
||||
};
|
||||
|
||||
reinstateSubscription = async (): Promise<void> => {
|
||||
const path = `${this.endpoint}/subscription/reinstate`;
|
||||
await this.apiService.send("POST", path, null, true, false);
|
||||
};
|
||||
|
||||
updateSubscriptionStorage = async (additionalStorageGb: number): Promise<void> => {
|
||||
const path = `${this.endpoint}/subscription/storage`;
|
||||
await this.apiService.send("PUT", path, { additionalStorageGb }, true, false);
|
||||
};
|
||||
|
||||
upgradePremiumToOrganization = async (
|
||||
organizationName: string,
|
||||
organizationKey: string,
|
||||
planTier: ProductTierType,
|
||||
cadence: SubscriptionCadence,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||
): Promise<void> => {
|
||||
const path = `${this.endpoint}/upgrade`;
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
path,
|
||||
{
|
||||
organizationName,
|
||||
key: organizationKey,
|
||||
targetProductTierType: planTier,
|
||||
cadence,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./organization-billing.client";
|
||||
export * from "./subscriber-billing.client";
|
||||
export * from "./tax.client";
|
||||
export * from "./preview-invoice.client";
|
||||
export * from "./account-billing.client";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
|
||||
@@ -16,6 +17,24 @@ class TaxAmountResponse extends BaseResponse implements TaxAmounts {
|
||||
}
|
||||
}
|
||||
|
||||
export class ProrationPreviewResponse extends BaseResponse {
|
||||
tax: number;
|
||||
total: number;
|
||||
credit: number;
|
||||
newPlanProratedMonths: number;
|
||||
newPlanProratedAmount: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.tax = this.getResponseProperty("Tax");
|
||||
this.total = this.getResponseProperty("Total");
|
||||
this.credit = this.getResponseProperty("Credit");
|
||||
this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths");
|
||||
this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount");
|
||||
}
|
||||
}
|
||||
|
||||
export type OrganizationSubscriptionPlan = {
|
||||
tier: "families" | "teams" | "enterprise";
|
||||
cadence: "annually" | "monthly";
|
||||
@@ -51,7 +70,7 @@ export interface TaxAmounts {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TaxClient {
|
||||
export class PreviewInvoiceClient {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
previewTaxForOrganizationSubscriptionPurchase = async (
|
||||
@@ -60,7 +79,7 @@ export class TaxClient {
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
"/billing/tax/organizations/subscriptions/purchase",
|
||||
"/billing/preview-invoice/organizations/subscriptions/purchase",
|
||||
{
|
||||
purchase,
|
||||
billingAddress,
|
||||
@@ -82,7 +101,7 @@ export class TaxClient {
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/organizations/${organizationId}/subscription/plan-change`,
|
||||
`/billing/preview-invoice/organizations/${organizationId}/subscription/plan-change`,
|
||||
{
|
||||
plan,
|
||||
billingAddress,
|
||||
@@ -100,7 +119,7 @@ export class TaxClient {
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/organizations/${organizationId}/subscription/update`,
|
||||
`/billing/preview-invoice/organizations/${organizationId}/subscription/update`,
|
||||
{
|
||||
update,
|
||||
},
|
||||
@@ -117,7 +136,7 @@ export class TaxClient {
|
||||
): Promise<TaxAmounts> => {
|
||||
const json = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/tax/premium/subscriptions/purchase`,
|
||||
`/billing/preview-invoice/premium/subscriptions/purchase`,
|
||||
{
|
||||
additionalStorage,
|
||||
billingAddress,
|
||||
@@ -128,4 +147,22 @@ export class TaxClient {
|
||||
|
||||
return new TaxAmountResponse(json);
|
||||
};
|
||||
|
||||
previewProrationForPremiumUpgrade = async (
|
||||
planTier: ProductTierType,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||
): Promise<ProrationPreviewResponse> => {
|
||||
const prorationResponse = await this.apiService.send(
|
||||
"POST",
|
||||
`/billing/preview-invoice/premium/subscriptions/upgrade`,
|
||||
{
|
||||
targetProductTierType: planTier,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new ProrationPreviewResponse(prorationResponse);
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { inject, NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
|
||||
import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component";
|
||||
import { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component";
|
||||
|
||||
import { BillingHistoryViewComponent } from "./billing-history-view.component";
|
||||
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
|
||||
@@ -17,11 +20,15 @@ const routes: Routes = [
|
||||
data: { titleId: "subscription" },
|
||||
children: [
|
||||
{ path: "", pathMatch: "full", redirectTo: "premium" },
|
||||
{
|
||||
path: "user-subscription",
|
||||
component: UserSubscriptionComponent,
|
||||
data: { titleId: "premiumMembership" },
|
||||
},
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: UserSubscriptionComponent,
|
||||
flaggedComponent: AccountSubscriptionComponent,
|
||||
featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage,
|
||||
routeOptions: {
|
||||
path: "user-subscription",
|
||||
data: { titleId: "premiumMembership" },
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Two-Route Matching Strategy for /premium:
|
||||
*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@if (subscriptionLoading()) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
@if (subscription.value(); as subscription) {
|
||||
<!-- Page Header -->
|
||||
<div
|
||||
class="tw-flex tw-flex-col tw-gap-3 tw-items-center tw-text-center tw-pb-8 tw-pt-12 tw-px-14"
|
||||
>
|
||||
<h1 bitTypography="h1" class="tw-m-0">{{ "youHavePremium" | i18n }}</h1>
|
||||
<p bitTypography="body1" class="tw-m-0 tw-text-muted">
|
||||
{{ "viewAndManagePremiumSubscription" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="tw-flex tw-flex-col tw-gap-10 tw-mx-auto tw-max-w-[800px] tw-pb-8">
|
||||
<!-- Premium Membership Card -->
|
||||
<billing-subscription-card
|
||||
[title]="'premiumMembership' | i18n"
|
||||
[subscription]="subscription"
|
||||
[showUpgradeButton]="canUpgradeFromPremium()"
|
||||
(callToActionClicked)="onSubscriptionCardAction($event)"
|
||||
/>
|
||||
|
||||
<!-- Storage Card -->
|
||||
@if (subscription.storage; as storage) {
|
||||
<billing-storage-card
|
||||
[storage]="storage"
|
||||
[addStorageDisabled]="!canAddStorage()"
|
||||
[removeStorageDisabled]="!canRemoveStorage()"
|
||||
(callToActionClicked)="onStorageCardAction($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Additional Options Card -->
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="subscriptionTerminal()"
|
||||
[cancelSubscriptionDisabled]="!canCancelSubscription()"
|
||||
(callToActionClicked)="onAdditionalOptionsCardAction($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, lastValueFrom, map, switchMap, of } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService, TypographyModule } from "@bitwarden/components";
|
||||
import { Maybe } from "@bitwarden/pricing";
|
||||
import {
|
||||
AdditionalOptionsCardAction,
|
||||
AdditionalOptionsCardActions,
|
||||
AdditionalOptionsCardComponent,
|
||||
MAX_STORAGE_GB,
|
||||
Storage,
|
||||
StorageCardAction,
|
||||
StorageCardActions,
|
||||
StorageCardComponent,
|
||||
SubscriptionCardAction,
|
||||
SubscriptionCardActions,
|
||||
SubscriptionCardComponent,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
AdjustAccountSubscriptionStorageDialogComponent,
|
||||
AdjustAccountSubscriptionStorageDialogParams,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component";
|
||||
import {
|
||||
OffboardingSurveyDialogResultType,
|
||||
openOffboardingSurvey,
|
||||
} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component";
|
||||
|
||||
import {
|
||||
PremiumOrgUpgradeDialogComponent,
|
||||
PremiumOrgUpgradeDialogParams,
|
||||
} from "../upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./account-subscription.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AdditionalOptionsCardComponent,
|
||||
I18nPipe,
|
||||
JslibModule,
|
||||
StorageCardComponent,
|
||||
SubscriptionCardComponent,
|
||||
TypographyModule,
|
||||
],
|
||||
providers: [AccountBillingClient],
|
||||
})
|
||||
export class AccountSubscriptionComponent {
|
||||
private accountService = inject(AccountService);
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private accountBillingClient = inject(AccountBillingClient);
|
||||
private billingAccountProfileStateService = inject(BillingAccountProfileStateService);
|
||||
private configService = inject(ConfigService);
|
||||
private dialogService = inject(DialogService);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private i18nService = inject(I18nService);
|
||||
private router = inject(Router);
|
||||
private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
readonly account = toSignal(this.accountService.activeAccount$);
|
||||
|
||||
readonly hasPremiumPersonally = toSignal(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
return this.billingAccountProfileStateService.hasPremiumPersonally$(account.id);
|
||||
}),
|
||||
),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly subscription = resource({
|
||||
loader: async () => {
|
||||
const redirectToPremiumPage = async (): Promise<null> => {
|
||||
await this.router.navigate(["/settings/subscription/premium"]);
|
||||
return null;
|
||||
};
|
||||
if (!this.account()) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
if (!this.hasPremiumPersonally()) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
return await this.accountBillingClient.getSubscription();
|
||||
},
|
||||
});
|
||||
|
||||
readonly subscriptionLoading = computed<boolean>(() => this.subscription.isLoading());
|
||||
|
||||
readonly subscriptionTerminal = computed<Maybe<boolean>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
if (subscription) {
|
||||
return (
|
||||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
|
||||
subscription.status === SubscriptionStatuses.Canceled ||
|
||||
subscription.status === SubscriptionStatuses.Unpaid
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
readonly subscriptionPendingCancellation = computed<Maybe<boolean>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
if (subscription) {
|
||||
return (
|
||||
(subscription.status === SubscriptionStatuses.Trialing ||
|
||||
subscription.status === SubscriptionStatuses.Active) &&
|
||||
!!subscription.cancelAt
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
readonly storage = computed<Maybe<Storage>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
return subscription?.storage;
|
||||
});
|
||||
|
||||
readonly purchasedStorage = computed<number | undefined>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
return subscription?.cart.passwordManager.additionalStorage?.quantity;
|
||||
});
|
||||
|
||||
readonly premiumPlan = toSignal(
|
||||
this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
map((tiers) =>
|
||||
tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
readonly premiumStoragePrice = computed<Maybe<number>>(() => {
|
||||
const premiumPlan = this.premiumPlan();
|
||||
return premiumPlan?.passwordManager.annualPricePerAdditionalStorageGB;
|
||||
});
|
||||
|
||||
readonly premiumProvidedStorage = computed<Maybe<number>>(() => {
|
||||
const premiumPlan = this.premiumPlan();
|
||||
return premiumPlan?.passwordManager.providedStorageGB;
|
||||
});
|
||||
|
||||
readonly canAddStorage = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
const storage = this.storage();
|
||||
const premiumProvidedStorage = this.premiumProvidedStorage();
|
||||
if (storage && premiumProvidedStorage) {
|
||||
const maxAttainableStorage = MAX_STORAGE_GB - premiumProvidedStorage;
|
||||
return storage.available < maxAttainableStorage;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canRemoveStorage = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
const purchasedStorage = this.purchasedStorage();
|
||||
if (!purchasedStorage || purchasedStorage === 0) {
|
||||
return false;
|
||||
}
|
||||
const storage = this.storage();
|
||||
if (storage) {
|
||||
return storage.available > storage.used;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canCancelSubscription = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
return !this.subscriptionPendingCancellation();
|
||||
});
|
||||
|
||||
readonly premiumToOrganizationUpgradeEnabled = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly canUpgradeFromPremium = computed<boolean>(() => {
|
||||
// Since account is checked in hasPremiumPersonally, no need to check again here
|
||||
const hasPremiumPersonally = this.hasPremiumPersonally();
|
||||
const upgradeEnabled = this.premiumToOrganizationUpgradeEnabled();
|
||||
return hasPremiumPersonally && upgradeEnabled;
|
||||
});
|
||||
|
||||
onSubscriptionCardAction = async (action: SubscriptionCardAction) => {
|
||||
switch (action) {
|
||||
case SubscriptionCardActions.ContactSupport:
|
||||
window.open("https://bitwarden.com/contact/", "_blank");
|
||||
break;
|
||||
case SubscriptionCardActions.ManageInvoices:
|
||||
await this.router.navigate(["../billing-history"], { relativeTo: this.activatedRoute });
|
||||
break;
|
||||
case SubscriptionCardActions.ReinstateSubscription: {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "reinstateSubscription" },
|
||||
content: { key: "reinstateConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.accountBillingClient.reinstateSubscription();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("reinstated"),
|
||||
});
|
||||
this.subscription.reload();
|
||||
break;
|
||||
}
|
||||
case SubscriptionCardActions.UpdatePayment:
|
||||
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
|
||||
break;
|
||||
case SubscriptionCardActions.UpgradePlan:
|
||||
await this.openUpgradeDialog();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onStorageCardAction = async (action: StorageCardAction) => {
|
||||
const data = this.getAdjustStorageDialogParams(action);
|
||||
const dialogReference = AdjustAccountSubscriptionStorageDialogComponent.open(
|
||||
this.dialogService,
|
||||
{
|
||||
data,
|
||||
},
|
||||
);
|
||||
const result = await lastValueFrom(dialogReference.closed);
|
||||
if (result === "submitted") {
|
||||
this.subscription.reload();
|
||||
}
|
||||
};
|
||||
|
||||
onAdditionalOptionsCardAction = async (action: AdditionalOptionsCardAction) => {
|
||||
switch (action) {
|
||||
case AdditionalOptionsCardActions.DownloadLicense: {
|
||||
const license = await this.accountBillingClient.getLicense();
|
||||
const json = JSON.stringify(license, null, 2);
|
||||
this.fileDownloadService.download({
|
||||
fileName: "bitwarden_premium_license.json",
|
||||
blobData: json,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AdditionalOptionsCardActions.CancelSubscription: {
|
||||
const dialogReference = openOffboardingSurvey(this.dialogService, {
|
||||
data: {
|
||||
type: "User",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogReference.closed);
|
||||
|
||||
if (result === OffboardingSurveyDialogResultType.Closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscription.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getAdjustStorageDialogParams = (
|
||||
action: StorageCardAction,
|
||||
): Maybe<AdjustAccountSubscriptionStorageDialogParams> => {
|
||||
const purchasedStorage = this.purchasedStorage();
|
||||
const storagePrice = this.premiumStoragePrice();
|
||||
const providedStorage = this.premiumProvidedStorage();
|
||||
|
||||
switch (action) {
|
||||
case StorageCardActions.AddStorage: {
|
||||
if (storagePrice && providedStorage) {
|
||||
return {
|
||||
type: "add",
|
||||
price: storagePrice,
|
||||
provided: providedStorage,
|
||||
cadence: "annually",
|
||||
existing: purchasedStorage,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StorageCardActions.RemoveStorage: {
|
||||
if (purchasedStorage) {
|
||||
return {
|
||||
type: "remove",
|
||||
existing: purchasedStorage,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
openUpgradeDialog = async (): Promise<void> => {
|
||||
const account = this.account();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogParams: PremiumOrgUpgradeDialogParams = {
|
||||
account,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
const dialogRef = PremiumOrgUpgradeDialogComponent.open(this.dialogService, {
|
||||
data: dialogParams,
|
||||
});
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@let content = this.content();
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [title]="content.title">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">{{ content.body }}</p>
|
||||
<div class="tw-grid tw-grid-cols-12">
|
||||
<bit-form-field class="tw-col-span-7">
|
||||
<bit-label>{{ content.label }}</bit-label>
|
||||
<input bitInput type="number" [formControl]="formGroup.controls.amount" />
|
||||
@if (action() === "add") {
|
||||
<bit-hint>
|
||||
<!-- Total: 10 GB × $0.50 = $5.00 /month -->
|
||||
<strong>{{ "total" | i18n }}</strong>
|
||||
{{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} =
|
||||
{{ price() * formGroup.value.amount | currency: "$" }} /
|
||||
{{ term() | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="submit"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="'closed'"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user