1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-22241] Add DefaultUserCollectionName support to bulk organization user confirmation (#15873)

* Add bulk user confirmation method to OrganizationUserService

* Update OrganizationUserBulkConfirmRequest to include optional defaultUserCollectionName property

* Implement conditional bulk user confirmation logic in BulkConfirmDialogComponent.

Its gated behind the feature flag for default user collection.

* Refactor OrganizationUserBulkConfirmRequest to use SdkEncString for defaultUserCollectionName

* Refactor BulkConfirmDialogComponent to use organization object instead of organizationId for improved clarity and type safety.

* Add unit tests for OrganizationUserService to validate user single/bulk confirmation logic

* Refactor OrganizationUserService to streamline encrypted collection name retrieval by introducing getEncryptedDefaultCollectionName$ method.

* Refactor unit tests for OrganizationUserService to reduce duplication by introducing a setupCommonMocks function for common mock configurations.

* refactor(organization-user.service): streamline retrieval of encrypted collection name in bulk confirmation process
This commit is contained in:
Rui Tomé
2025-08-05 15:34:17 +01:00
committed by GitHub
parent 2a3e1ae1f5
commit 40a1a0a2b7
5 changed files with 237 additions and 17 deletions

View File

@@ -11,10 +11,13 @@ import {
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
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";
@@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
import { BulkUserDetails } from "./bulk-status.component";
type BulkConfirmDialogParams = {
organizationId: string;
organization: Organization;
users: BulkUserDetails[];
};
@@ -36,7 +41,7 @@ type BulkConfirmDialogParams = {
standalone: false,
})
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
organizationId: string;
organization: Organization;
organizationKey$: Observable<OrgKey>;
users: BulkUserDetails[];
@@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
private organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
this.organizationId = dialogParams.organizationId;
this.organization = dialogParams.organization;
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]),
takeUntilDestroyed(),
);
this.users = dialogParams.users;
@@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
ListResponse<OrganizationUserBulkPublicKeyResponse | ProviderUserBulkPublicKeyResponse>
> =>
await this.organizationUserApiService.postOrganizationUsersPublicKey(
this.organizationId,
this.organization.id,
this.filteredUsers.map((user) => user.id),
);
@@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organizationId,
request,
);
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,
);
}
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -721,7 +721,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
organization: this.organization,
users: this.dataSource.getCheckedUsers(),
},
});

View File

@@ -0,0 +1,175 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "./organization-user.service";
describe("OrganizationUserService", () => {
let service: OrganizationUserService;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let accountService: jest.Mocked<AccountService>;
let i18nService: jest.Mocked<I18nService>;
const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId;
const mockOrganizationUser = new OrganizationUserView();
mockOrganizationUser.id = "user-123";
const mockPublicKey = new Uint8Array(64) as CsprngArray;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
const mockDefaultCollectionName = "My Items";
const setupCommonMocks = () => {
keyService.orgKeys$.mockReturnValue(
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
i18nService.t.mockReturnValue(mockDefaultCollectionName);
};
beforeEach(() => {
keyService = {
orgKeys$: jest.fn(),
} as any;
encryptService = {
encryptString: jest.fn(),
encapsulateKeyUnsigned: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
postOrganizationUserBulkConfirm: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" }),
} as any;
i18nService = {
t: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationUserService,
{ provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: i18nService },
],
});
service = TestBed.inject(OrganizationUserService);
});
describe("confirmUser", () => {
beforeEach(() => {
setupCommonMocks();
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
});
it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
mockOrgKey,
mockPublicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockOrganizationUser.id,
{
key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
} as OrganizationUserConfirmRequest,
);
done();
},
error: done,
});
});
});
describe("bulkConfirmUsers", () => {
const mockUserIdsWithKeys = [
{ id: "user-1", key: "key-1" },
{ id: "user-2", key: "key-2" },
];
const mockBulkResponse = {
data: [
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
],
} as ListResponse<OrganizationUserBulkResponse>;
beforeEach(() => {
setupCommonMocks();
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
Promise.resolve(mockBulkResponse),
);
});
it("should bulk confirm users successfully", (done) => {
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
next: (response) => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
mockOrganization.id,
new OrganizationUserBulkConfirmRequest(
mockUserIdsWithKeys,
mockEncryptedCollectionName.encryptedString,
),
);
expect(response).toEqual(mockBulkResponse);
done();
},
error: done,
});
});
});
});

View File

@@ -3,12 +3,15 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -41,11 +44,7 @@ export class OrganizationUserService {
user: OrganizationUserView,
publicKey: Uint8Array,
): Observable<void> {
const encryptedCollectionName$ = this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe(
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
@@ -66,4 +65,31 @@ export class OrganizationUserService {
}),
);
}
bulkConfirmUsers(
organization: Organization,
userIdsWithKeys: { id: string; key: string }[],
): Observable<ListResponse<OrganizationUserBulkResponse>> {
return this.getEncryptedDefaultCollectionName$(organization).pipe(
switchMap((collectionName) => {
const request = new OrganizationUserBulkConfirmRequest(
userIdsWithKeys,
collectionName.encryptedString,
);
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
organization.id,
request,
);
}),
);
}
private getEncryptedDefaultCollectionName$(organization: Organization) {
return this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
}
}