diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index c2091d1a452..26637c8f8ec 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -1,5 +1,5 @@ - @if (!(isLoading$ | async)) { + @if (!isLoading()) { -} @else if (isLoading$ | async) { +} @else if (isLoading()) {
(); protected searchControl = new FormControl("", { nonNullable: true }); - protected organizationId: OrganizationId; - protected orgIsOnSecretsManagerStandalone: boolean; - protected isLoading$ = new BehaviorSubject(true); + protected organizationId!: OrganizationId; + protected orgIsOnSecretsManagerStandalone!: boolean; + protected readonly isLoading = signal(true); /** Current progress state for the loading component */ protected readonly currentProgressStep = signal(null); @@ -120,7 +118,7 @@ export class MemberAccessReportComponent implements OnInit { } async ngOnInit() { - this.isLoading$.next(true); + this.isLoading.set(true); const params = await firstValueFrom(this.route.params); this.organizationId = params.organizationId; @@ -133,7 +131,7 @@ export class MemberAccessReportComponent implements OnInit { await this.load(); - this.isLoading$.next(false); + this.isLoading.set(false); } async load() { diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts index 902beb37eee..7b67e23d5cb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -10,11 +8,11 @@ import { MemberAccessReportView } from "../view/member-access-report.view"; export abstract class MemberAccessReportServiceAbstraction { /** Observable for progress state updates during report generation */ - progress$: Observable; - generateMemberAccessReportView: ( + abstract readonly progress$: Observable; + abstract generateMemberAccessReportView( organizationId: OrganizationId, - ) => Promise; - generateUserReportExportItems: ( + ): Promise; + abstract generateUserReportExportItems( organizationId: OrganizationId, - ) => Promise; + ): Promise; } diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts index 637566b4228..1fcded89940 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { CollectionAdminService, OrganizationUserApiService, + OrganizationUserUserDetailsResponse, } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections"; @@ -66,43 +67,52 @@ describe("MemberAccessReportService", () => { describe("(No Name) fallback", () => { it("should use '(No Name)' when user has empty name string", async () => { // Setup mock data with empty name - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "", // Empty name - email: "user@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group1"], - avatarColor: null, - } as any, - ], - } as ListResponse); - - mockCollectionAdminService.collectionAdminViews$.mockReturnValue( - of([ - { - id: "col1", - name: "Test Collection", - groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], - users: [], - } as CollectionAdminView, - ]), + Data: [ + { + id: "user1", + name: "", // Empty name + email: "user@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group1"], + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), ); - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Test Group", - collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + const collection = new CollectionAdminView({ + id: "col1" as any, + name: "Test Collection", + organizationId: mockOrganizationId, }); + Object.assign(collection, { + groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], + users: [], + }); + + mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([collection])); + + const group = new GroupDetailsView(); + Object.assign(group, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Test Group", + externalId: null, + collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], + }); + + mockGroupApiService.getAllDetails.mockResolvedValue([group]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first (required before export) await service.generateMemberAccessReportView(mockOrganizationId); @@ -119,43 +129,52 @@ describe("MemberAccessReportService", () => { it("should use actual name when user has non-empty name", async () => { // Setup mock data with actual name - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "John Doe", - email: "john@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group1"], - avatarColor: null, - } as any, - ], - } as ListResponse); - - mockCollectionAdminService.collectionAdminViews$.mockReturnValue( - of([ - { - id: "col1", - name: "Test Collection", - groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], - users: [], - } as CollectionAdminView, - ]), + Data: [ + { + id: "user1", + name: "John Doe", + email: "john@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group1"], + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), ); - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Test Group", - collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + const collection = new CollectionAdminView({ + id: "col1" as any, + name: "Test Collection", + organizationId: mockOrganizationId, }); + Object.assign(collection, { + groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], + users: [], + }); + + mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([collection])); + + const group = new GroupDetailsView(); + Object.assign(group, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Test Group", + externalId: null, + collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], + }); + + mockGroupApiService.getAllDetails.mockResolvedValue([group]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -172,26 +191,31 @@ describe("MemberAccessReportService", () => { it("should use '(No Name)' for users with no collection or group access", async () => { // Setup mock data with empty name and no access - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "", // Empty name - email: "user@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: [], // No groups - avatarColor: null, - } as any, - ], - } as ListResponse); + Data: [ + { + id: "user1", + name: "", // Empty name + email: "user@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: [], // No groups + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), + ); mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([])); mockGroupApiService.getAllDetails.mockResolvedValue([]); - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], - }); + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -211,37 +235,45 @@ describe("MemberAccessReportService", () => { describe("Groups with no collections", () => { it("should include group membership even when group has no collections", async () => { // Setup: user in a group, but group has no collections - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "Jane Doe", - email: "jane@example.com", - twoFactorEnabled: true, - resetPasswordEnrolled: true, - usesKeyConnector: false, - groups: ["group1"], // User is in group1 - avatarColor: null, - } as any, - ], - } as ListResponse); + Data: [ + { + id: "user1", + name: "Jane Doe", + email: "jane@example.com", + twoFactorEnabled: true, + resetPasswordEnrolled: true, + usesKeyConnector: false, + groups: ["group1"], // User is in group1 + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), + ); // No collections at all mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([])); // Group exists but has no collections - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Empty Group", - collections: [], // No collections - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + const group = new GroupDetailsView(); + Object.assign(group, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Empty Group", + externalId: null, + collections: [], // No collections }); + mockGroupApiService.getAllDetails.mockResolvedValue([group]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); + // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -260,50 +292,62 @@ describe("MemberAccessReportService", () => { it("should create separate rows for groups with collections and groups without", async () => { // Setup: user in two groups, one with collections and one without - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "Multi Group User", - email: "multi@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group1", "group2"], // In both groups - avatarColor: null, - } as any, - ], - } as ListResponse); - - // One collection assigned to group1 - mockCollectionAdminService.collectionAdminViews$.mockReturnValue( - of([ - { - id: "col1", - name: "Collection 1", - groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: true }], - users: [], - } as CollectionAdminView, - ]), + Data: [ + { + id: "user1", + name: "Multi Group User", + email: "multi@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group1", "group2"], // In both groups + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), ); - // group1 has collection, group2 does not - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Group With Collection", - collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: true }], - } as GroupDetailsView, - { - id: "group2", - name: "Group Without Collection", - collections: [], - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + // One collection assigned to group1 + const collection = new CollectionAdminView({ + id: "col1" as any, + name: "Collection 1", + organizationId: mockOrganizationId, }); + Object.assign(collection, { + groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: true }], + users: [], + }); + + mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([collection])); + + // group1 has collection, group2 does not + const group1 = new GroupDetailsView(); + Object.assign(group1, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Group With Collection", + externalId: null, + collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: true }], + }); + const group2 = new GroupDetailsView(); + Object.assign(group2, { + id: "group2", + organizationId: mockOrganizationId as any, + name: "Group Without Collection", + externalId: null, + collections: [], + }); + + mockGroupApiService.getAllDetails.mockResolvedValue([group1, group2]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -332,53 +376,68 @@ describe("MemberAccessReportService", () => { it("should show multiple collections for group with collections", async () => { // Setup: user in group with multiple collections - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "User Name", - email: "user@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group1"], - avatarColor: null, - } as any, - ], - } as ListResponse); - - // Two collections both assigned to group1 - mockCollectionAdminService.collectionAdminViews$.mockReturnValue( - of([ - { - id: "col1", - name: "Collection 1", - groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], - users: [], - } as CollectionAdminView, - { - id: "col2", - name: "Collection 2", - groups: [{ id: "group1", readOnly: true, hidePasswords: false, manage: false }], - users: [], - } as CollectionAdminView, - ]), + Data: [ + { + id: "user1", + name: "User Name", + email: "user@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group1"], + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), ); - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Multi Collection Group", - collections: [ - { id: "col1", readOnly: false, hidePasswords: false, manage: false }, - { id: "col2", readOnly: true, hidePasswords: false, manage: false }, - ], - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + // Two collections both assigned to group1 + const collection1 = new CollectionAdminView({ + id: "col1" as any, + name: "Collection 1", + organizationId: mockOrganizationId, }); + Object.assign(collection1, { + groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], + users: [], + }); + + const collection2 = new CollectionAdminView({ + id: "col2" as any, + name: "Collection 2", + organizationId: mockOrganizationId, + }); + Object.assign(collection2, { + groups: [{ id: "group1", readOnly: true, hidePasswords: false, manage: false }], + users: [], + }); + + mockCollectionAdminService.collectionAdminViews$.mockReturnValue( + of([collection1, collection2]), + ); + + const group = new GroupDetailsView(); + Object.assign(group, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Multi Collection Group", + externalId: null, + collections: [ + { id: "col1", readOnly: false, hidePasswords: false, manage: false }, + { id: "col2", readOnly: true, hidePasswords: false, manage: false }, + ], + }); + + mockGroupApiService.getAllDetails.mockResolvedValue([group]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -399,35 +458,43 @@ describe("MemberAccessReportService", () => { describe("Combined scenarios", () => { it("should handle user with empty name in group with no collections", async () => { // Combines both fixes: empty name + group without collections - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "", - email: "noname@example.com", - twoFactorEnabled: true, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group1"], - avatarColor: null, - } as any, - ], - } as ListResponse); + Data: [ + { + id: "user1", + name: "", + email: "noname@example.com", + twoFactorEnabled: true, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group1"], + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), + ); mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([])); - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "No Collection Group", - collections: [], - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + const group = new GroupDetailsView(); + Object.assign(group, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "No Collection Group", + externalId: null, + collections: [], }); + mockGroupApiService.getAllDetails.mockResolvedValue([group]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); + // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); @@ -442,58 +509,70 @@ describe("MemberAccessReportService", () => { it("should handle multiple users with mixed name and group scenarios", async () => { // Complex scenario with multiple users - mockOrganizationUserApiService.getAllUsers.mockResolvedValue({ - data: [ + mockOrganizationUserApiService.getAllUsers.mockResolvedValue( + new ListResponse( { - id: "user1", - name: "Alice", - email: "alice@example.com", - twoFactorEnabled: true, - resetPasswordEnrolled: true, - usesKeyConnector: false, - groups: ["group1"], - avatarColor: null, - } as any, - { - id: "user2", - name: "", - email: "bob@example.com", - twoFactorEnabled: false, - resetPasswordEnrolled: false, - usesKeyConnector: false, - groups: ["group2"], - avatarColor: null, - } as any, - ], - } as ListResponse); - - mockCollectionAdminService.collectionAdminViews$.mockReturnValue( - of([ - { - id: "col1", - name: "Collection A", - groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], - users: [], - } as CollectionAdminView, - ]), + Data: [ + { + id: "user1", + name: "Alice", + email: "alice@example.com", + twoFactorEnabled: true, + resetPasswordEnrolled: true, + usesKeyConnector: false, + groups: ["group1"], + avatarColor: null, + }, + { + id: "user2", + name: "", + email: "bob@example.com", + twoFactorEnabled: false, + resetPasswordEnrolled: false, + usesKeyConnector: false, + groups: ["group2"], + avatarColor: null, + }, + ], + }, + OrganizationUserUserDetailsResponse, + ), ); - mockGroupApiService.getAllDetails.mockResolvedValue([ - { - id: "group1", - name: "Group A", - collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], - } as GroupDetailsView, - { - id: "group2", - name: "Group B", - collections: [], // No collections - } as GroupDetailsView, - ]); - - mockApiService.getCiphersOrganization.mockResolvedValue({ - data: [] as CipherResponse[], + const collection = new CollectionAdminView({ + id: "col1" as any, + name: "Collection A", + organizationId: mockOrganizationId, }); + Object.assign(collection, { + groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }], + users: [], + }); + + mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([collection])); + + const group1 = new GroupDetailsView(); + Object.assign(group1, { + id: "group1", + organizationId: mockOrganizationId as any, + name: "Group A", + externalId: null, + collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }], + }); + const group2 = new GroupDetailsView(); + Object.assign(group2, { + id: "group2", + organizationId: mockOrganizationId as any, + name: "Group B", + externalId: null, + collections: [], // No collections + }); + + mockGroupApiService.getAllDetails.mockResolvedValue([group1, group2]); + + mockApiService.getCiphersOrganization.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, CipherResponse), + ); // Generate report view first await service.generateMemberAccessReportView(mockOrganizationId); diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index 99121041368..cfa94df8ba9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Injectable } from "@angular/core"; import { BehaviorSubject, firstValueFrom, Observable } from "rxjs"; @@ -83,7 +81,8 @@ export class MemberAccessReportService { private progressSubject = new BehaviorSubject(null); /** Observable for progress state updates */ - progress$: Observable = this.progressSubject.asObservable(); + readonly progress$: Observable = + this.progressSubject.asObservable(); /** Cached lookup maps for export generation */ private cachedLookupMaps: LookupMaps | null = null; @@ -492,8 +491,9 @@ export class MemberAccessReportService { hidePasswords: access.hidePasswords, manage: access.manage, }); - return this.i18nService.t( - permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId, - ); + const labelId = + permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView)) + ?.labelId ?? "canView"; + return this.i18nService.t(labelId); } }