1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -1,3 +1,5 @@
# Bitwarden Web App
<p align="center">
<img src="https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-vault.png" alt="" width="600" height="358" />
</p>

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.12.0",
"version": "2026.1.1",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -0,0 +1,130 @@
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,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PeopleTableDataSource } from "./people-table-data-source";
interface MockUser {
id: string;
name: string;
email: string;
status: OrganizationUserStatusType;
checked?: boolean;
}
class TestPeopleTableDataSource extends PeopleTableDataSource<any> {
protected statusType = OrganizationUserStatusType;
}
describe("PeopleTableDataSource", () => {
let dataSource: TestPeopleTableDataSource;
const createMockUser = (id: string, checked: boolean = false): MockUser => ({
id,
name: `User ${id}`,
email: `user${id}@example.com`,
status: OrganizationUserStatusType.Confirmed,
checked,
});
const createMockUsers = (count: number, checked: boolean = false): MockUser[] => {
return Array.from({ length: count }, (_, i) => createMockUser(`${i + 1}`, checked));
};
beforeEach(() => {
const featureFlagSubject = new ReplaySubject<boolean>(1);
featureFlagSubject.next(false);
const environmentSubject = new ReplaySubject<Environment>(1);
environmentSubject.next({
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 },
],
});
dataSource = TestBed.runInInjectionContext(
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
);
});
describe("limitAndUncheckExcess", () => {
it("should return all users when under limit", () => {
const users = createMockUsers(10, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(10);
expect(result).toEqual(users);
expect(users.every((u) => u.checked)).toBe(true);
});
it("should limit users and uncheck excess", () => {
const users = createMockUsers(600, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(500);
expect(result).toEqual(users.slice(0, 500));
expect(users.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(users.slice(500).every((u) => u.checked)).toBe(false);
});
it("should only affect users in the provided array", () => {
const allUsers = createMockUsers(1000, true);
dataSource.data = allUsers;
// Pass only a subset (simulates filtering by status)
const subset = allUsers.slice(0, 600);
const result = dataSource.limitAndUncheckExcess(subset, 500);
expect(result).toHaveLength(500);
expect(subset.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(subset.slice(500).every((u) => u.checked)).toBe(false);
// Users outside subset remain checked
expect(allUsers.slice(600).every((u) => u.checked)).toBe(true);
});
});
describe("status counts", () => {
it("should correctly count users by status", () => {
const users: MockUser[] = [
{ ...createMockUser("1"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("2"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("3"), status: OrganizationUserStatusType.Accepted },
{ ...createMockUser("4"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("5"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("6"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("7"), status: OrganizationUserStatusType.Revoked },
];
dataSource.data = users;
expect(dataSource.invitedUserCount).toBe(2);
expect(dataSource.acceptedUserCount).toBe(1);
expect(dataSource.confirmedUserCount).toBe(3);
expect(dataSource.revokedUserCount).toBe(1);
expect(dataSource.activeUserCount).toBe(6); // All except revoked
});
});
});

View File

@@ -1,14 +1,36 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { computed, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Observable, Subject, map } from "rxjs";
import {
OrganizationUserStatusType,
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.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 { TableDataSource } from "@bitwarden/components";
import { StatusType, UserViewTypes } from "./base-members.component";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
const MaxCheckedCount = 500;
export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
export type UserViewTypes = ProviderUser | OrganizationUserView;
export type ProviderUser = ProviderUserUserDetailsResponse;
/**
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
*/
export const MaxCheckedCount = 500;
/**
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
* feature flag is enabled on cloud environments.
*/
export const CloudBulkReinviteLimit = 8000;
/**
* Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked).
@@ -56,6 +78,20 @@ 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) */
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
constructor(configService: ConfigService, environmentService: EnvironmentService) {
super();
const featureFlagEnabled = toSignal(
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
}
override set data(data: T[]) {
super.data = data;
@@ -70,6 +106,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() {
@@ -82,6 +120,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;
}
@@ -89,6 +136,20 @@ 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).
*/
getCheckedUsersInVisibleOrder() {
return this.filteredData.filter((u) => (u as any).checked);
}
/**
* Check all filtered users (i.e. those rows that are currently visible)
* @param select check the filtered users (true) or uncheck the filtered users (false)
@@ -101,11 +162,18 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
const filteredUsers = this.filteredData;
const selectCount =
filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
// When the increased bulk limit feature is enabled, allow checking all users.
// Individual bulk operations will enforce their specific limits.
// When disabled, enforce the legacy limit at check time.
const selectCount = this.isIncreasedBulkLimitEnabled()
? filteredUsers.length
: 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() {
@@ -132,4 +200,67 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
this.data = updatedData;
}
}
/**
* Limits an array of users and unchecks those beyond the limit.
* Returns the limited array.
*
* @param users The array of users to limit
* @param limit The maximum number of users to keep
* @returns The users array limited to the specified count
*/
limitAndUncheckExcess(users: T[], limit: number): T[] {
if (users.length <= limit) {
return users;
}
// Uncheck users beyond the limit
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.
*
* 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)
* @returns The checked users array
*/
getCheckedUsersWithLimit(limit: number): T[] {
if (this.isIncreasedBulkLimitEnabled()) {
const allUsers = this.getCheckedUsersInVisibleOrder();
return this.limitAndUncheckExcess(allUsers, limit);
} else {
return this.getCheckedUsers();
}
}
}
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
);
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -1,2 +1 @@
export * from "./utils";
export * from "./collection-badge";

View File

@@ -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",

View File

@@ -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);
});
});
});

View File

@@ -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];
});
}

View File

@@ -1 +0,0 @@
export * from "./collection-utils";

View File

@@ -11,18 +11,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
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
@@ -48,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,
@@ -59,6 +60,7 @@ export class VaultFilterComponent
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
vaultFilterService,
@@ -72,6 +74,7 @@ export class VaultFilterComponent
restrictedItemTypesService,
cipherService,
cipherArchiveService,
premiumUpgradePromptService,
);
}

View File

@@ -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";

View File

@@ -1,19 +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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 {
@@ -35,7 +33,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider: StateProvider,
collectionService: CollectionService,
accountService: AccountService,
configService: ConfigService,
) {
super(
organizationService,
@@ -46,7 +43,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider,
collectionService,
accountService,
configService,
);
}

View File

@@ -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

View File

@@ -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";
@@ -587,6 +587,9 @@ export class VaultComponent implements OnInit, OnDestroy {
queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText },
queryParamsHandling: "merge",
replaceUrl: true,
state: {
focusMainAfterNav: false,
},
}),
);

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,70 +0,0 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

View File

@@ -2,12 +2,15 @@
<app-side-nav variant="secondary" *ngIf="organization$ | async as organization">
<bit-nav-logo [openIcon]="logo" route="." [label]="'adminConsole' | i18n"></bit-nav-logo>
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-item
icon="bwi-dashboard"
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
@if (canShowAccessIntelligenceTab(organization)) {
<bit-nav-item
icon="bwi-dashboard"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
}
<bit-nav-item
icon="bwi-collection-shared"
[text]="'collections' | i18n"
@@ -101,12 +104,12 @@
*ngIf="organization.use2fa && organization.isOwner"
></bit-nav-item>
<bit-nav-item
[text]="'importData' | i18n"
[text]="'importNoun' | i18n"
route="settings/tools/import"
*ngIf="organization.canAccessImport"
></bit-nav-item>
<bit-nav-item
[text]="'exportVault' | i18n"
[text]="'exportNoun' | i18n"
route="settings/tools/export"
*ngIf="canAccessExport$ | async"
></bit-nav-item>

View File

@@ -8,6 +8,7 @@ import { combineLatest, filter, map, Observable, switchMap, withLatestFrom } fro
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AdminConsoleLogo } from "@bitwarden/assets/svg";
import {
canAccessAccessIntelligence,
canAccessBillingTab,
canAccessGroupsTab,
canAccessMembersTab,
@@ -172,6 +173,10 @@ export class OrganizationLayoutComponent implements OnInit {
return canAccessBillingTab(organization);
}
canShowAccessIntelligenceTab(organization: Organization): boolean {
return canAccessAccessIntelligence(organization);
}
getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports";
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>) {

View File

@@ -6,7 +6,6 @@ import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkConfirmRequest,
OrganizationUserBulkPublicKeyResponse,
OrganizationUserBulkResponse,
OrganizationUserService,
@@ -15,10 +14,8 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -39,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 {
@@ -54,7 +52,6 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
@@ -84,19 +81,9 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
} else {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organization.id,
request,
);
}
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -18,7 +18,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,
})

View File

@@ -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,
})

View File

@@ -15,11 +15,8 @@ import {
} from "rxjs";
import {
CollectionAccessSelectionView,
CollectionAdminService,
CollectionAdminView,
OrganizationUserApiService,
CollectionView,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -30,6 +27,11 @@ import {
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";

View File

@@ -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>
{{ "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>
}

View File

@@ -0,0 +1,616 @@
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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 configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, 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.map((user) => user.id as UserId),
);
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);
}
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));
}
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);
}
};
}

View File

@@ -1 +1,2 @@
export * from "./members.module";
export * from "./pipes";

View File

@@ -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,

View File

@@ -1,5 +1,10 @@
@let organization = this.organization();
@if (organization) {
@let dataSource = this.dataSource();
@let bulkActions = bulkMenuOptions$ | async;
@let showConfirmBanner = showConfirmBanner$ | async;
@let isProcessing = this.isProcessing();
@if (organization && dataSource) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
@@ -12,173 +17,199 @@
[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 class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
@if (showUserManagementControls()) {
<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 }}"
></button>
</div>
</th>
}
<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>
{{ "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>
@@ -190,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
@@ -208,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
@@ -253,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)="
@@ -304,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"
@@ -313,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)"
@@ -323,160 +338,169 @@
>
{{ 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>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<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()">
@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>
}
}
}

View File

@@ -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.id]);
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");
});
});
});

View File

@@ -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,31 +33,46 @@ 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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 { PeopleTableDataSource } from "../../common/people-table-data-source";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
MembersTableDataSource,
peopleFilter,
showConfirmBanner,
} 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, OrganizationMembersService } from "./services";
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;
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
@@ -66,65 +81,76 @@ 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 = new 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 configService = inject(ConfigService);
private environmentService = inject(EnvironmentService);
private memberExportService = inject(MemberExportService);
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.configService, 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 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 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,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
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) =>
@@ -167,7 +193,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!);
}
@@ -201,80 +227,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(
@@ -290,7 +298,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
orgUser: OrganizationUserView,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
@@ -301,9 +309,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,
@@ -313,14 +327,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();
}
}
@@ -341,7 +347,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:
@@ -352,58 +358,47 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
await this.memberDialogManager.openBulkRemoveDialog(
organization,
this.dataSource.getCheckedUsers(),
);
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;
}
await this.memberDialogManager.openBulkDeleteDialog(
organization,
this.dataSource.getCheckedUsers(),
);
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;
}
await this.memberDialogManager.openBulkRestoreRevokeDialog(
organization,
this.dataSource.getCheckedUsers(),
isRevoking,
);
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 users = this.dataSource.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
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({
@@ -414,47 +409,59 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
try {
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id),
);
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
if (!result.successful) {
throw new Error();
if (!result.successful) {
this.validationService.showError(result.failed);
}
// 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()),
});
}
// Bulk Status component open
} 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;
}
await this.memberDialogManager.openBulkConfirmDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers();
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
this.dataSource.uncheckAllUsers();
this.dataSource().uncheckAllUsers();
await this.load(organization);
}
@@ -482,14 +489,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,
@@ -500,48 +499,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: this.i18nService.t(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 = () => {
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);
}
};
}

View File

@@ -17,12 +17,15 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.
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,
MemberActionsService,
MemberDialogManagerService,
MemberExportService,
} from "./services";
@NgModule({
@@ -44,13 +47,17 @@ import {
BulkRestoreRevokeComponent,
BulkStatusComponent,
MembersComponent,
vNextMembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
MemberExportService,
UserStatusPipe,
],
})
export class MembersModule {}

View File

@@ -0,0 +1 @@
export * from "./user-status.pipe";

View File

@@ -0,0 +1,47 @@
import { MockProxy, mock } from "jest-mock-extended";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserStatusPipe } from "./user-status.pipe";
describe("UserStatusPipe", () => {
let pipe: UserStatusPipe;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
pipe = new UserStatusPipe(i18nService);
});
it("transforms OrganizationUserStatusType.Invited to 'invited'", () => {
expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited");
expect(i18nService.t).toHaveBeenCalledWith("invited");
});
it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => {
expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted");
expect(i18nService.t).toHaveBeenCalledWith("accepted");
});
it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => {
expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed");
expect(i18nService.t).toHaveBeenCalledWith("confirmed");
});
it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => {
expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked");
expect(i18nService.t).toHaveBeenCalledWith("revoked");
});
it("transforms null to 'unknown'", () => {
expect(pipe.transform(null)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
it("transforms undefined to 'unknown'", () => {
expect(pipe.transform(undefined)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
});

View File

@@ -0,0 +1,30 @@
import { Pipe, PipeTransform } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Pipe({
name: "userStatus",
standalone: false,
})
export class UserStatusPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserStatusType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserStatusType.Invited:
return this.i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return this.i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return this.i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return this.i18nService.t("revoked");
default:
return this.i18nService.t("unknown");
}
}
}

View File

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

View File

@@ -1,42 +1,40 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } 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,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
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 { OrganizationUserService } from "../organization-user/organization-user.service";
import { MemberActionsService } from "./member-actions.service";
import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service";
describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
@@ -46,10 +44,7 @@ describe("MemberActionsService", () => {
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = {
@@ -68,15 +63,29 @@ describe("MemberActionsService", () => {
resetPasswordEnrolled: true,
} as OrganizationUserView;
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
keyService,
encryptService,
configService,
accountService,
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>() },
],
});
service = TestBed.inject(MemberActionsService);
});
describe("inviteUser", () => {
@@ -242,8 +251,7 @@ describe("MemberActionsService", () => {
describe("confirmUser", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should confirm user using new flow when feature flag is enabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it("should confirm user", async () => {
organizationUserService.confirmUser.mockReturnValue(of(undefined));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
@@ -257,44 +265,7 @@ describe("MemberActionsService", () => {
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should confirm user using exising flow when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const mockOrgKey = mock<OrgKey>();
const mockOrgKeys = { [organizationId]: mockOrgKey };
keyService.orgKeys$.mockReturnValue(of(mockOrgKeys));
const mockEncryptedKey = new EncString("encrypted-key-data");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(keyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
organizationId,
userIdToManage,
expect.objectContaining({
key: "encrypted-key-data",
}),
);
});
it("should handle missing organization keys", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
keyService.orgKeys$.mockReturnValue(of({}));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain("Organization keys not found");
});
it("should handle confirm errors", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const errorMessage = "Confirm failed";
organizationUserService.confirmUser.mockImplementation(() => {
throw new Error(errorMessage);
@@ -308,41 +279,308 @@ describe("MemberActionsService", () => {
});
describe("bulkReinvite", () => {
const userIds = [newGuid(), newGuid(), newGuid()];
const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId];
it("should successfully reinvite multiple users", async () => {
const mockResponse = {
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
} as ListResponse<OrganizationUserBulkResponse>;
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result).toEqual({
successful: mockResponse,
failed: [],
describe("when feature flag is false", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it("should successfully reinvite multiple users", async () => {
const mockResponse = new ListResponse(
{
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.failed).toEqual([]);
expect(result.successful).toBeDefined();
expect(result.successful).toEqual(mockResponse);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
});
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new 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) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const result = await service.bulkReinvite(mockOrganization, userIds);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
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,
);
});
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 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.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,
);
});
});
});
@@ -427,14 +665,6 @@ describe("MemberActionsService", () => {
expect(result).toBe(false);
});
it("should not allow reset password when organization lacks public and private keys", () => {
const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when user is not enrolled in reset password", () => {
const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView;
@@ -443,12 +673,6 @@ describe("MemberActionsService", () => {
expect(result).toBe(false);
});
it("should not allow reset password when reset password is disabled", () => {
const result = service.allowResetPassword(mockOrgUser, mockOrganization, false);
expect(result).toBe(false);
});
it("should not allow reset password when user status is not confirmed", () => {
const user = {
...mockOrgUser,
@@ -460,4 +684,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);
});
});
});

View File

@@ -1,27 +1,35 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import { inject, Injectable, signal } from "@angular/core";
import { lastValueFrom, firstValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
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,
} 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 { 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { 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 { UserId } from "@bitwarden/user-core";
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";
export const REQUESTS_PER_BATCH = 500;
export interface MemberActionResult {
success: boolean;
@@ -35,17 +43,26 @@ export interface BulkActionResult {
@Injectable()
export class MemberActionsService {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
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);
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
private accountService: AccountService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
readonly isProcessing = signal(false);
private startProcessing(): void {
this.isProcessing.set(true);
}
private endProcessing(): void {
this.isProcessing.set(false);
}
async inviteUser(
organization: Organization,
@@ -55,6 +72,7 @@ export class MemberActionsService {
collections?: any[],
groups?: string[],
): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
emails: [email],
@@ -67,55 +85,72 @@ 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);
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();
}
}
@@ -124,58 +159,52 @@ export class MemberActionsService {
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
this.startProcessing();
try {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async bulkReinvite(organization: Organization, userIds: string[]): Promise<BulkActionResult> {
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
this.startProcessing();
try {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
return { successful: result, failed: [] };
if (increaseBulkReinviteLimitForCloud) {
return await this.vNextBulkReinvite(organization, userIds);
} else {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
}
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
} finally {
this.endProcessing();
}
}
async vNextBulkReinvite(
organization: Organization,
userIds: UserId[],
): Promise<BulkActionResult> {
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
);
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
@@ -207,4 +236,98 @@ export class MemberActionsService {
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
/**
* Processes user IDs in sequential batches and aggregates results.
* @param userIds - Array of user IDs to process
* @param batchSize - Number of IDs to process per batch
* @param processBatch - Async function that processes a single batch and returns the result
* @returns Aggregated bulk action result
*/
private async processBatchedOperation(
userIds: UserId[],
batchSize: number,
processBatch: (batch: string[]) => 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);
try {
const result = await processBatch(batch);
if (result?.data) {
for (const response of result.data) {
if (response.error) {
allFailed.push({ id: response.id, error: response.error });
} else {
allSuccessful.push(response);
}
}
}
} catch (error) {
allFailed.push(
...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
);
}
}
const successful =
allSuccessful.length > 0
? new ListResponse(allSuccessful, OrganizationUserBulkResponse)
: undefined;
return {
successful,
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}`);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./member.export";
export * from "./member-export.service";

View File

@@ -0,0 +1,182 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
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";
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) => {
const translations: Record<string, string> = {
// Column headers
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
// Status values
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
// Role values
owner: "Owner",
admin: "Admin",
user: "User",
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;
});
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: FileDownloadService, useValue: fileDownloadService },
{ provide: LogService, useValue: logService },
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
],
});
service = TestBed.inject(MemberExportService);
});
describe("getMemberExport", () => {
it("should export members with all fields populated", () => {
const members: OrganizationUserView[] = [
{
email: "user1@example.com",
name: "User One",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
twoFactorEnabled: true,
resetPasswordEnrolled: true,
accessSecretsManager: true,
groupNames: ["Group A", "Group B"],
} as OrganizationUserView,
{
email: "user2@example.com",
name: "User Two",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Group C"],
} as OrganizationUserView,
];
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");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Admin");
expect(csvData).toContain("user2@example.com");
expect(csvData).toContain("User Two");
expect(csvData).toContain("Invited");
});
it("should handle members with null name", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: null,
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: [],
} as OrganizationUserView,
];
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");
});
it("should handle members with no groups", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: null,
} as OrganizationUserView,
];
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 result = service.getMemberExport([]);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("No members to export");
expect(fileDownloadService.download).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,82 @@
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";
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(data: OrganizationUserView[]): MemberExportResult {
try {
const members = data;
if (!members || members.length === 0) {
return { success: false, error: { message: this.i18nService.t("noMembersToExport") } };
}
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
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 } };
}
}
private getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -0,0 +1,43 @@
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
export class MemberExport {
/**
* @param user Organization user to export
* @returns a Record<string, string> of each column header key, value
* All property members must be a string for export purposes. Null and undefined will appear as
* "null" in a .csv export, therefore an empty string is preferable to a nullish type.
*/
static fromOrganizationUserView(
i18nService: I18nService,
userTypePipe: UserTypePipe,
userStatusPipe: UserStatusPipe,
user: OrganizationUserView,
): Record<string, string> {
const result = {
[i18nService.t("email")]: user.email,
[i18nService.t("name")]: user.name ?? "",
[i18nService.t("status")]: userStatusPipe.transform(user.status),
[i18nService.t("role")]: userTypePipe.transform(user.type),
[i18nService.t("twoStepLogin")]: user.twoFactorEnabled
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("accountRecovery")]: user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled"),
[i18nService.t("secretsManager")]: user.accessSecretsManager
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("groups")]: user.groupNames?.join(", ") ?? "",
};
return result;
}
}

View File

@@ -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";

View File

@@ -35,13 +35,10 @@ import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-pa
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService
implements
UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
>
{
export class OrganizationUserResetPasswordService implements UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
> {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,

View File

@@ -2,6 +2,6 @@ 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";

View File

@@ -1,27 +1,34 @@
<app-header></app-header>
@let organization = organization$ | async;
@let policiesEnabledMap = policiesEnabledMap$ | async;
@let organizationId = organizationId$ | async;
<bit-container>
@if (loading) {
@if (!organization || !policiesEnabledMap || !organizationId) {
<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>
}
@if (!loading) {
} @else {
<bit-table>
<ng-template body>
@for (p of policies$ | async; track p.type) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
@for (p of policies$ | async; 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>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
}
}
</ng-template>
</bit-table>

View File

@@ -0,0 +1,548 @@
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, firstValueFrom } from "rxjs";
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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import { PoliciesComponent } from "./policies.component";
import { SingleOrgPolicy } from "./policy-edit-definitions/single-org.component";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
describe("PoliciesComponent", () => {
let component: PoliciesComponent;
let fixture: ComponentFixture<PoliciesComponent>;
let mockActivatedRoute: ActivatedRoute;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockAccountService: FakeAccountService;
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
let mockPolicyListService: MockProxy<PolicyListService>;
let mockDialogService: MockProxy<DialogService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockConfigService: MockProxy<ConfigService>;
let mockI18nService: MockProxy<I18nService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
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,
} as Organization;
const mockPolicyResponse = {
id: newGuid(),
enabled: true,
object: "policy",
organizationId: mockOrgId,
type: PolicyType.SingleOrg,
data: null,
};
const mockPolicy = new SingleOrgPolicy();
beforeEach(async () => {
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
queryParamsSubject = new BehaviorSubject({});
mockActivatedRoute = {
params: routeParamsSubject.asObservable(),
queryParams: queryParamsSubject.asObservable(),
} as any;
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
mockAccountService = mockAccountServiceWith(mockUserId);
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [mockPolicyResponse], ContinuationToken: null }, PolicyResponse),
);
mockPolicyListService = mock<PolicyListService>();
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
mockDialogService = mock<DialogService>();
mockDialogService.open.mockReturnValue({ close: jest.fn() } as any);
mockPolicyService = mock<PolicyService>();
mockPolicyService.policies$.mockReturnValue(of([]));
mockConfigService = mock<ConfigService>();
mockI18nService = mock<I18nService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
jest.spyOn(PolicyEditDialogComponent, "open").mockReturnValue({ close: jest.fn() } as any);
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
if (fixture) {
fixture.destroy();
}
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("organizationId$", () => {
it("should extract organizationId from route params", async () => {
const orgId = await firstValueFrom(component.organizationId$);
expect(orgId).toBe(mockOrgId);
});
it("should emit new organizationId when route params change", (done) => {
const newOrgId = newGuid() as OrganizationId;
const emittedValues: OrganizationId[] = [];
const subscription = component.organizationId$.subscribe((orgId) => {
emittedValues.push(orgId);
if (emittedValues.length === 2) {
expect(emittedValues[0]).toBe(mockOrgId);
expect(emittedValues[1]).toBe(newOrgId);
subscription.unsubscribe();
done();
}
});
routeParamsSubject.next({ organizationId: newOrgId });
});
});
describe("organization$", () => {
it("should retrieve organization for current user and organizationId", async () => {
const org = await firstValueFrom(component.organization$);
expect(org).toBe(mockOrg);
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when organization is not found", async () => {
mockOrganizationService.organizations$.mockReturnValue(of([]));
await expect(firstValueFrom(component.organization$)).rejects.toThrow(
"No organization found for provided userId",
);
});
});
describe("policies$", () => {
it("should return policies from PolicyListService", async () => {
const policies = await firstValueFrom(component.policies$);
expect(policies).toBeDefined();
expect(Array.isArray(policies)).toBe(true);
});
});
describe("orgPolicies$", () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: newGuid(),
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
},
{
id: newGuid(),
organizationId: mockOrgId,
type: PolicyType.RequireSso,
enabled: false,
data: null,
},
];
beforeEach(async () => {
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should fetch policies from API for current organization", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies.length).toBe(2);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
});
});
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns no data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
describe("with null data", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns null data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
});
describe("policiesEnabledMap$", () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: "policy-1",
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
},
{
id: "policy-2",
organizationId: mockOrgId,
type: PolicyType.RequireSso,
enabled: false,
data: null,
},
{
id: "policy-3",
organizationId: mockOrgId,
type: PolicyType.SingleOrg,
enabled: true,
data: null,
},
];
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create a map of policy types to their enabled status", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
});
});
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create empty map when no policies exist", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
});
});
});
describe("constructor subscription", () => {
it("should subscribe to policyService.policies$ on initialization", () => {
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
});
describe("when policyService emits", () => {
let policiesSubject: BehaviorSubject<any[]>;
let callCount: number;
beforeEach(async () => {
policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
});
fixture = TestBed.createComponent(PoliciesComponent);
fixture.detectChanges();
});
it("should refresh policies when policyService emits", () => {
const initialCallCount = callCount;
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
expect(callCount).toBeGreaterThan(initialCallCount);
});
});
});
describe("handleLaunchEvent", () => {
describe("when policyId is in query params", () => {
const mockPolicyId = newGuid();
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const mockPolicyResponseData = {
id: mockPolicyId,
organizationId: mockOrgId,
type: PolicyType.TwoFactorAuthentication,
enabled: true,
data: null,
};
let dialogOpenSpy: jest.SpyInstance;
beforeEach(async () => {
queryParamsSubject.next({ policyId: mockPolicyId });
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
),
),
);
dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should open policy dialog when policyId is in query params", () => {
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
});
});
it("should not open dialog when policyId is not in query params", async () => {
const editSpy = jest.spyOn(component, "edit");
queryParamsSubject.next({});
expect(editSpy).not.toHaveBeenCalled();
});
it("should not open dialog when policyId does not match any org policy", async () => {
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const editSpy = jest.spyOn(component, "edit");
queryParamsSubject.next({ policyId: "non-existent-policy-id" });
expect(editSpy).not.toHaveBeenCalled();
});
});
describe("edit", () => {
it("should call dialogService.open with correct parameters when no custom dialog is specified", () => {
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.TwoFactorAuthentication,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, mockOrgId);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: mockOrgId,
},
});
});
it("should call custom dialog open method when specified", () => {
const mockDialogRef = { close: jest.fn() };
const mockCustomDialog = {
open: jest.fn().mockReturnValue(mockDialogRef),
};
const mockPolicy: BasePolicyEditDefinition = {
name: "Custom Policy",
description: "Custom Description",
type: PolicyType.RequireSso,
component: {} as any,
editDialogComponent: mockCustomDialog as any,
showDescription: true,
display$: () => of(true),
};
component.edit(mockPolicy, mockOrgId);
expect(mockCustomDialog.open).toHaveBeenCalled();
const callArgs = mockCustomDialog.open.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: mockOrgId,
},
});
expect(PolicyEditDialogComponent.open).not.toHaveBeenCalled();
});
it("should pass correct organizationId to dialog", () => {
const customOrgId = newGuid() as OrganizationId;
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
type: PolicyType.SingleOrg,
component: {} as any,
showDescription: true,
display$: () => of(true),
};
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, customOrgId);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organizationId: customOrgId,
},
});
});
});
});

View File

@@ -1,31 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
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 { 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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { safeProvider } from "@bitwarden/ui-common";
@@ -37,8 +25,6 @@ import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "policies.component.html",
imports: [SharedModule, HeaderModule],
@@ -48,14 +34,54 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
deps: [POLICY_EDIT_REGISTER],
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PoliciesComponent implements OnInit {
loading = true;
organizationId: string;
policies$: Observable<BasePolicyEditDefinition[]>;
export class PoliciesComponent {
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
private orgPolicies: PolicyResponse[];
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
protected organizationId$: Observable<OrganizationId> = this.route.params.pipe(
map((params) => params.organizationId),
);
protected organization$: Observable<Organization> = combineLatest([
this.userId$,
this.organizationId$,
]).pipe(
switchMap(([userId, orgId]) =>
this.organizationService.organizations$(userId).pipe(
getById(orgId),
map((org) => {
if (org == null) {
throw new Error("No organization found for provided userId");
}
return org;
}),
),
),
);
protected policies$: Observable<readonly BasePolicyEditDefinition[]> = of(
this.policyListService.getPolicies(),
);
private orgPolicies$: Observable<PolicyResponse[]> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
switchMap(() => this.organizationId$),
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(
map((orgPolicies) => {
const policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
orgPolicies.forEach((op) => {
policiesEnabledMap.set(op.type, op.enabled);
});
return policiesEnabledMap;
}),
);
constructor(
private route: ActivatedRoute,
@@ -66,60 +92,28 @@ export class PoliciesComponent implements OnInit {
private dialogService: DialogService,
private policyService: PolicyService,
protected configService: ConfigService,
private destroyRef: DestroyRef,
) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
tap(async () => await this.load()),
takeUntilDestroyed(),
)
.subscribe();
this.handleLaunchEvent();
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const organization$ = this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId));
this.policies$ = organization$.pipe(
withLatestFrom(of(this.policyListService.getPolicies())),
switchMap(([organization, policies]) => {
return combineLatest(
policies.map((policy) =>
policy
.display$(organization, this.configService)
.pipe(map((shouldDisplay) => ({ policy, shouldDisplay }))),
),
);
}),
map((results) =>
results.filter((result) => result.shouldDisplay).map((result) => result.policy),
),
);
await this.load();
// Handle policies component launch from Event message
combineLatest([this.route.queryParams.pipe(first()), this.policies$])
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
.subscribe(async ([qParams, policies]) => {
// Handle policies component launch from Event message
private handleLaunchEvent() {
combineLatest([
this.route.queryParams.pipe(first()),
this.policies$,
this.organizationId$,
this.orgPolicies$,
])
.pipe(
map(([qParams, policies, organizationId, orgPolicies]) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
for (const orgPolicy of orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < policies.length; i++) {
if (policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(policies[i]);
this.edit(policies[i], organizationId);
break;
}
}
@@ -127,27 +121,19 @@ export class PoliciesComponent implements OnInit {
}
}
}
});
});
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
async load() {
const response = await this.policyApiService.getPolicies(this.organizationId);
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
this.orgPolicies.forEach((op) => {
this.policiesEnabledMap.set(op.type, op.enabled);
});
this.loading = false;
}
async edit(policy: BasePolicyEditDefinition) {
edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organizationId: this.organizationId,
organizationId: organizationId,
},
});
}

View File

@@ -1,4 +1,11 @@
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
OnInit,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
@@ -8,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";
@@ -26,11 +33,11 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auto-confirm-policy-edit",
templateUrl: "auto-confirm-policy.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -18,10 +18,10 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio
return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype);
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "autotype-policy-edit",
templateUrl: "autotype-policy.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DesktopAutotypeDefaultSettingPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class DisableSendPolicy extends BasePolicyEditDefinition {
component = DisableSendPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "disable-send-policy-edit",
templateUrl: "disable-send.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DisableSendPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -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";

View File

@@ -32,6 +32,7 @@
formControlName="minLength"
id="minLength"
[min]="MinPasswordLength"
[max]="MaxPasswordLength"
/>
</bit-form-field>
</div>

View File

@@ -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]);
});
});

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -26,18 +26,22 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
component = MasterPasswordPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "master-password-policy-edit",
templateUrl: "master-password.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
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],

View File

@@ -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>

View File

@@ -1,31 +1,102 @@
import { Component } from "@angular/core";
import { map, 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> {
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems)
.pipe(map((enabled) => !enabled));
}
}
// 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: "organization-data-ownership-policy-edit",
templateUrl: "organization-data-ownership.component.html",
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;
}
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { BehaviorSubject, map } from "rxjs";
@@ -19,11 +19,11 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition {
component = PasswordGeneratorPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "password-generator-policy-edit",
templateUrl: "password-generator.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PasswordGeneratorPolicyComponent extends BasePolicyEditComponent {
// these properties forward the application default settings to the UI

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition {
component = RemoveUnlockWithPinPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "remove-unlock-with-pin-policy-edit",
templateUrl: "remove-unlock-with-pin.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemoveUnlockWithPinPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -19,10 +19,10 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "require-sso-policy-edit",
templateUrl: "require-sso.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RequireSsoPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom, of } from "rxjs";
@@ -26,11 +26,11 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "reset-password-policy-edit",
templateUrl: "reset-password.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResetPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
data = this.formBuilder.group({

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,11 +12,11 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition {
component = RestrictedItemTypesPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "restricted-item-types-policy-edit",
templateUrl: "restricted-item-types.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RestrictedItemTypesPolicyComponent extends BasePolicyEditComponent {
constructor() {

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -13,11 +13,11 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition {
component = SendOptionsPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "send-options-policy-edit",
templateUrl: "send-options.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendOptionsPolicyComponent extends BasePolicyEditComponent {
data = this.formBuilder.group({

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,11 +12,11 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition {
component = SingleOrgPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "single-org-policy-edit",
templateUrl: "single-org.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SingleOrgPolicyComponent extends BasePolicyEditComponent implements OnInit {
async ngOnInit() {

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,10 +12,10 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition {
component = TwoFactorAuthenticationPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "two-factor-authentication-policy-edit",
templateUrl: "two-factor-authentication.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TwoFactorAuthenticationPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -19,6 +19,7 @@ export class UriMatchDefaultPolicy extends BasePolicyEditDefinition {
component = UriMatchDefaultPolicyComponent;
}
@Component({
selector: "uri-match-default-policy-edit",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "uri-match-default.component.html",
imports: [SharedModule],

View File

@@ -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>

View File

@@ -1,5 +1,14 @@
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } 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";
@@ -9,13 +18,13 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
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;
@@ -23,49 +32,40 @@ 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.CreateDefaultLocation);
return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
}
}
// 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: "vnext-organization-data-ownership-policy-edit",
templateUrl: "vnext-organization-data-ownership.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class vNextOrganizationDataOwnershipPolicyComponent
extends BasePolicyEditComponent
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) {
@@ -97,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
return encrypted.encryptedString;
}
setStep(step: number) {
this.step.set(step);
}
}

View File

@@ -14,10 +14,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
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 { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import {
DIALOG_DATA,
DialogConfig,
@@ -30,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 = {
/**
@@ -75,14 +74,24 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
protected keyService: KeyService,
) {}
get policy(): BasePolicyEditDefinition {
return this.data.policy;
}
/**
* 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.
*/
@@ -132,10 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
if (
this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent &&
(await this.isVNextEnabled())
) {
if (this.hasVNextRequest(this.policyComponent)) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -154,14 +160,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
};
private async isVNextEnabled(): Promise<boolean> {
const isVNextFeatureEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
);
return isVNextFeatureEnabled;
}
private async handleStandardSubmission(): Promise<void> {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
@@ -172,7 +170,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(
@@ -187,12 +187,12 @@ 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>) => {

View File

@@ -22,7 +22,7 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
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";
@@ -30,7 +30,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 {
@@ -42,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;
@@ -115,7 +109,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
@@ -131,7 +124,6 @@ export class AutoConfirmPolicyDialogComponent
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);
@@ -205,6 +197,7 @@ export class AutoConfirmPolicyDialogComponent
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
@@ -238,7 +231,7 @@ export class AutoConfirmPolicyDialogComponent
data: null,
};
await this.policyApiService.putPolicy(
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
@@ -263,7 +256,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");

View File

@@ -0,0 +1,3 @@
export * from "./auto-confirm-edit-policy-dialog.component";
export * from "./organization-data-ownership-edit-policy-dialog.component";
export * from "./models";

View File

@@ -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>;
};

View File

@@ -0,0 +1,72 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [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>
{{ policy.name | i18n }}
</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>

View File

@@ -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.open<PolicyEditDialogResult>(
OrganizationDataOwnershipPolicyDialogComponent,
config,
);
};
}

View File

@@ -57,7 +57,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)],
data: {
titleId: "importData",
titleId: "importNoun",
},
},
{
@@ -68,7 +68,7 @@ const routes: Routes = [
),
canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)],
data: {
titleId: "exportVault",
titleId: "exportNoun",
},
},
],

View File

@@ -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";

View File

@@ -1,4 +1,4 @@
import { action } from "@storybook/addon-actions";
import { action } from "storybook/actions";
import { AccessItemType, AccessItemView } from "./access-selector.models";

View File

@@ -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";

View File

@@ -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";

View File

@@ -61,8 +61,11 @@ export class WebLoginComponentService
email: string,
state: string,
codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> {
await this.router.navigate(["/sso"]);
await this.router.navigate(["/sso"], {
queryParams: { identifier: orgSsoIdentifier },
});
return;
}

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -16,14 +17,17 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.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 { 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";
@@ -45,6 +49,8 @@ describe("WebSetInitialPasswordService", () => {
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let organizationInviteService: MockProxy<OrganizationInviteService>;
let routerService: MockProxy<RouterService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let registerSdkService: MockProxy<RegisterSdkService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -59,6 +65,8 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
organizationInviteService = mock<OrganizationInviteService>();
routerService = mock<RouterService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
registerSdkService = mock<RegisterSdkService>();
sut = new WebSetInitialPasswordService(
apiService,
@@ -73,6 +81,8 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptionsService,
organizationInviteService,
routerService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -204,4 +214,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();
});
});
});

View File

@@ -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,
@@ -10,9 +11,11 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
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";
@@ -34,6 +37,8 @@ export class WebSetInitialPasswordService
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -46,6 +51,8 @@ export class WebSetInitialPasswordService
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
}
@@ -80,4 +87,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();
}
}

View File

@@ -39,9 +39,7 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service
/**
* Service for managing WebAuthnLogin credentials.
*/
export class WebauthnLoginAdminService
implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest>
{
export class WebauthnLoginAdminService implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest> {
static readonly MaxCredentialCount = 5;
private navigatorCredentials: CredentialsContainer;

View File

@@ -45,13 +45,10 @@ import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-acc
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService
implements
UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
>
{
export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
> {
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
private apiService: ApiService,

View File

@@ -1,21 +1,37 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { DeleteRecoverRequest } from "@bitwarden/common/models/request/delete-recover.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// 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-recover-delete",
templateUrl: "recover-delete.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
TypographyModule,
],
})
export class RecoverDeleteComponent {
protected recoverDeleteForm = new FormGroup({
@@ -29,7 +45,6 @@ export class RecoverDeleteComponent {
constructor(
private router: Router,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private toastService: ToastService,
) {}

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { Router, provideRouter } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import {
@@ -7,69 +7,49 @@ import {
LoginSuccessHandlerService,
PasswordLoginCredentials,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ErrorResponse } from "@bitwarden/common/models/response/error.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { I18nPipe } from "@bitwarden/ui-common";
import { RecoverTwoFactorComponent } from "./recover-two-factor.component";
describe("RecoverTwoFactorComponent", () => {
let component: RecoverTwoFactorComponent;
let fixture: ComponentFixture<RecoverTwoFactorComponent>;
// Mock Services
let mockRouter: MockProxy<Router>;
let mockApiService: MockProxy<ApiService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockI18nService: MockProxy<I18nService>;
let mockKeyService: MockProxy<KeyService>;
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let mockToastService: MockProxy<ToastService>;
let mockConfigService: MockProxy<ConfigService>;
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
let mockLogService: MockProxy<LogService>;
let mockValidationService: MockProxy<ValidationService>;
beforeEach(() => {
mockRouter = mock<Router>();
mockApiService = mock<ApiService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockKeyService = mock<KeyService>();
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockToastService = mock<ToastService>();
mockConfigService = mock<ConfigService>();
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
mockLogService = mock<LogService>();
mockValidationService = mock<ValidationService>();
TestBed.configureTestingModule({
declarations: [RecoverTwoFactorComponent],
await TestBed.configureTestingModule({
imports: [RecoverTwoFactorComponent],
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: ApiService, useValue: mockApiService },
{ provide: PlatformUtilsService, mockPlatformUtilsService },
provideRouter([]),
{ provide: I18nService, useValue: mockI18nService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
{ provide: ToastService, useValue: mockToastService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
{ provide: LogService, useValue: mockLogService },
{ provide: ValidationService, useValue: mockValidationService },
],
imports: [I18nPipe],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownElements: false,
});
}).compileComponents();
mockRouter = TestBed.inject(Router) as MockProxy<Router>;
jest.spyOn(mockRouter, "navigate");
fixture = TestBed.createComponent(RecoverTwoFactorComponent);
component = fixture.componentInstance;

View File

@@ -1,8 +1,9 @@
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginStrategyServiceAbstraction,
PasswordLoginCredentials,
@@ -14,14 +15,32 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
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 { ToastService } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
LinkModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// 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-recover-two-factor",
templateUrl: "recover-two-factor.component.html",
standalone: false,
imports: [
ReactiveFormsModule,
RouterLink,
JslibModule,
AsyncActionsModule,
ButtonModule,
FormFieldModule,
I18nPipe,
LinkModule,
TypographyModule,
],
})
export class RecoverTwoFactorComponent implements OnInit {
formGroup = new FormGroup({
@@ -108,7 +127,7 @@ export class RecoverTwoFactorComponent implements OnInit {
message: this.i18nService.t("twoStepRecoverDisabled"),
});
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) {

View File

@@ -19,7 +19,7 @@
>
<ng-container *ngIf="currentCipher.organizationId">
<i
class="bwi bwi-collection-shared"
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
@@ -28,7 +28,7 @@
</ng-container>
<ng-container *ngIf="currentCipher.hasAttachments">
<i
class="bwi bwi-paperclip"
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"

View File

@@ -16,27 +16,26 @@
<img class="tw-float-right tw-ml-5 mfaType7" alt="FIDO2 WebAuthn logo" />
<ul class="bwi-ul">
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-medium">
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
</span>
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
{{ k.name }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
<ng-container *ngIf="k.migrated">
<span>{{ "webAuthnMigrated" | i18n }}</span>
<ng-container *ngIf="k.configured">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="k.configured" bitTypography="body1" class="tw-font-medium">
{{ k.name || ("unnamedKey" | i18n) }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
<ng-container *ngIf="k.migrated">
<span>{{ "webAuthnMigrated" | i18n }}</span>
</ng-container>
</ng-container>
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
<i
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
title="{{ 'loading' | i18n }}"
*ngIf="$any(removeKeyBtn).loading"
aria-hidden="true"
></i>
-
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
</ng-container>
</ng-container>
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
<i
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
title="{{ 'loading' | i18n }}"
*ngIf="$any(removeKeyBtn).loading"
aria-hidden="true"
></i>
-
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
</ng-container>
</li>
</ul>
@@ -60,7 +59,9 @@
type="button"
[bitAction]="readKey"
buttonType="secondary"
[disabled]="$any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable"
[disabled]="
$any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable || formGroup.invalid
"
class="tw-mr-2"
#readKeyBtn
>

View File

@@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, Inject, NgZone } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -99,7 +99,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
toastService,
);
this.formGroup = new FormGroup({
name: new FormControl({ value: "", disabled: false }),
name: new FormControl({ value: "", disabled: false }, Validators.required),
});
this.auth(data);
}
@@ -213,7 +213,22 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
this.webAuthnListening = listening;
}
private findNextAvailableKeyId(existingIds: Set<number>): number {
// Search for first gap, bounded by current key count + 1
for (let i = 1; i <= existingIds.size + 1; i++) {
if (!existingIds.has(i)) {
return i;
}
}
// This should never be reached due to loop bounds, but TypeScript requires a return
throw new Error("Unable to find next available key ID");
}
private processResponse(response: TwoFactorWebAuthnResponse) {
if (!response.keys || response.keys.length === 0) {
response.keys = [];
}
this.resetWebAuthn();
this.keys = [];
this.keyIdAvailable = null;
@@ -223,26 +238,37 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
nameControl.setValue("");
}
this.keysConfiguredCount = 0;
for (let i = 1; i <= 5; i++) {
if (response.keys != null) {
const key = response.keys.filter((k) => k.id === i);
if (key.length > 0) {
this.keysConfiguredCount++;
this.keys.push({
id: i,
name: key[0].name,
configured: true,
migrated: key[0].migrated,
removePromise: null,
});
continue;
}
}
this.keys.push({ id: i, name: "", configured: false, removePromise: null });
if (this.keyIdAvailable == null) {
this.keyIdAvailable = i;
}
// Build configured keys
for (const key of response.keys) {
this.keysConfiguredCount++;
this.keys.push({
id: key.id,
name: key.name,
configured: true,
migrated: key.migrated,
removePromise: null,
});
}
// [PM-20109]: To accommodate the existing form logic with minimal changes,
// we need to have at least one unconfigured key slot available to the collection.
// Prior to PM-20109, both client and server had hard checks for IDs <= 5.
// While we don't have any technical constraints _at this time_, we should avoid
// unbounded growth of key IDs over time as users add/remove keys;
// this strategy gap-fills key IDs.
const existingIds = new Set(response.keys.map((k) => k.id));
const nextId = this.findNextAvailableKeyId(existingIds);
// Add unconfigured slot, which can be used to add a new key
this.keys.push({
id: nextId,
name: "",
configured: false,
removePromise: null,
});
this.keyIdAvailable = nextId;
this.enabled = response.enabled;
this.onUpdated.emit(this.enabled);
}

Some files were not shown because too many files have changed in this diff Show More