mirror of
https://github.com/bitwarden/browser
synced 2026-03-02 19:41:26 +00:00
Members access testing
This commit is contained in:
@@ -83,6 +83,13 @@ export class ReportsHomeComponent implements OnInit {
|
||||
? ReportVariant.Enabled
|
||||
: ReportVariant.RequiresEnterprise,
|
||||
},
|
||||
{
|
||||
...reports[ReportType.MemberAccessReportPrototype],
|
||||
variant:
|
||||
productType == ProductTierType.Enterprise
|
||||
? ReportVariant.Enabled
|
||||
: ReportVariant.RequiresEnterprise,
|
||||
},
|
||||
{
|
||||
...reports[ReportType.RiskInsightsPrototype],
|
||||
variant: reportRequiresUpgrade,
|
||||
|
||||
@@ -20,6 +20,7 @@ export enum ReportType {
|
||||
Inactive2fa = "inactive2fa",
|
||||
DataBreach = "dataBreach",
|
||||
MemberAccessReport = "memberAccessReport",
|
||||
MemberAccessReportPrototype = "memberAccessReportPrototype",
|
||||
RiskInsightsPrototype = "riskInsightsPrototype",
|
||||
}
|
||||
|
||||
@@ -68,6 +69,12 @@ export const reports: Record<ReportType, ReportWithoutVariant> = {
|
||||
route: "member-access-report",
|
||||
icon: UserLockIcon,
|
||||
},
|
||||
[ReportType.MemberAccessReportPrototype]: {
|
||||
title: "memberAccessReportPrototype",
|
||||
description: "memberAccessReportPrototypeDesc",
|
||||
route: "member-access-report-prototype",
|
||||
icon: UserLockIcon,
|
||||
},
|
||||
[ReportType.RiskInsightsPrototype]: {
|
||||
title: "riskInsightsPrototype",
|
||||
description: "riskInsightsPrototypeDesc",
|
||||
|
||||
@@ -10715,6 +10715,12 @@
|
||||
"memberAccessReportDesc": {
|
||||
"message": "Ensure members have access to the right credentials and their accounts are secure. Use this report to obtain a CSV of member access and account configurations."
|
||||
},
|
||||
"memberAccessReportPrototype": {
|
||||
"message": "Member access (Prototype)"
|
||||
},
|
||||
"memberAccessReportPrototypeDesc": {
|
||||
"message": "Prototype: Faster member access report using client-side data assembly instead of the server-side API."
|
||||
},
|
||||
"memberAccessReportPageDesc": {
|
||||
"message": "Audit organization member access across groups, collections, and collection items. The CSV export provides a detailed breakdown per member, including information on collection permissions and account configurations."
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./api-models.types";
|
||||
export * from "./member-access-report.types";
|
||||
export * from "./password-health";
|
||||
export * from "./report-data-service.types";
|
||||
export * from "./report-encryption.types";
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Effective permission level for a member's access to ciphers (const object pattern per ADR-0025)
|
||||
* Priority order: Manage > Edit > ViewOnly > HidePasswords
|
||||
*/
|
||||
export const EffectivePermissionLevel = Object.freeze({
|
||||
Manage: "manage",
|
||||
Edit: "edit",
|
||||
ViewOnly: "view-only",
|
||||
HidePasswords: "hide-passwords",
|
||||
} as const);
|
||||
export type EffectivePermissionLevel =
|
||||
(typeof EffectivePermissionLevel)[keyof typeof EffectivePermissionLevel];
|
||||
|
||||
/**
|
||||
* Loading states for the member access report (const object pattern per ADR-0025)
|
||||
*/
|
||||
export const MemberAccessReportState = Object.freeze({
|
||||
Idle: "idle",
|
||||
LoadingCiphers: "loading-ciphers",
|
||||
ProcessingMembers: "processing-members",
|
||||
Complete: "complete",
|
||||
Error: "error",
|
||||
} as const);
|
||||
export type MemberAccessReportState =
|
||||
(typeof MemberAccessReportState)[keyof typeof MemberAccessReportState];
|
||||
|
||||
/**
|
||||
* Summary view model for a single member's access across all ciphers.
|
||||
* This is the member-centric view pivoted from cipher-centric data.
|
||||
*/
|
||||
export interface MemberAccessSummary {
|
||||
/** The user's organization member ID */
|
||||
userId: string;
|
||||
|
||||
/** The user's email address */
|
||||
email: string;
|
||||
|
||||
/** The user's display name (may be null if not set) */
|
||||
name: string | null;
|
||||
|
||||
/** Total number of ciphers this member has access to */
|
||||
cipherCount: number;
|
||||
|
||||
/** Number of unique collections this member has access to */
|
||||
collectionCount: number;
|
||||
|
||||
/** Number of unique groups this member belongs to that grant cipher access */
|
||||
groupCount: number;
|
||||
|
||||
/** The highest permission level across all access paths */
|
||||
highestPermission: EffectivePermissionLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive result emitted during streaming member access report generation.
|
||||
* Enables incremental UI updates as batches of cipher data are processed.
|
||||
*/
|
||||
export interface MemberAccessReportProgressiveResult {
|
||||
/** Current state of report generation */
|
||||
state: MemberAccessReportState;
|
||||
|
||||
/** Member summaries computed so far (grows with each batch) */
|
||||
members: MemberAccessSummary[];
|
||||
|
||||
/** Number of ciphers processed so far */
|
||||
processedCipherCount: number;
|
||||
|
||||
/** Total number of ciphers to process */
|
||||
totalCipherCount: number;
|
||||
|
||||
/** Percentage complete (0-100) */
|
||||
progressPercent: number;
|
||||
|
||||
/** Error message if state is Error */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail of a member's access to a specific collection via a specific access path.
|
||||
* Groups ciphers by collection+accessType+groupId for detailed breakdown.
|
||||
*/
|
||||
export interface MemberCollectionAccessDetail {
|
||||
/** The collection ID */
|
||||
collectionId: string;
|
||||
|
||||
/** The collection display name */
|
||||
collectionName: string;
|
||||
|
||||
/** Number of ciphers accessible via this path */
|
||||
cipherCount: number;
|
||||
|
||||
/** Permission level for this access path */
|
||||
permission: EffectivePermissionLevel;
|
||||
|
||||
/** How access was granted: "direct" or "group" */
|
||||
accessType: "direct" | "group";
|
||||
|
||||
/** Group name if accessType is "group", null otherwise */
|
||||
groupName: string | null;
|
||||
|
||||
/** Group ID if accessType is "group", null otherwise */
|
||||
groupId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed view of a single member's access across all collections.
|
||||
* Used for displaying drill-down information in the detail dialog.
|
||||
*/
|
||||
export interface MemberAccessDetailView {
|
||||
/** The user's organization member ID */
|
||||
userId: string;
|
||||
|
||||
/** The user's email address */
|
||||
email: string;
|
||||
|
||||
/** The user's display name (may be null if not set) */
|
||||
name: string | null;
|
||||
|
||||
/** Total number of unique ciphers this member can access */
|
||||
totalCipherCount: number;
|
||||
|
||||
/** Breakdown of access by collection and access type */
|
||||
collectionDetails: MemberCollectionAccessDetail[];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -176,6 +177,7 @@ export class CipherAccessMappingService {
|
||||
private readonly cipherService = inject(CipherService);
|
||||
private readonly collectionAdminService = inject(CollectionAdminService);
|
||||
private readonly organizationUserApiService = inject(OrganizationUserApiService);
|
||||
private readonly apiService = inject(ApiService);
|
||||
private readonly logService = inject(LogService);
|
||||
|
||||
/** Cache for organization users to avoid duplicate API calls */
|
||||
@@ -738,7 +740,7 @@ export class CipherAccessMappingService {
|
||||
let groupData = groupMemberMap.get(groupId);
|
||||
if (!groupData) {
|
||||
// Initialize group data (name will be updated if we have it)
|
||||
groupData = { groupName: "Unknown Group", memberIds: [] };
|
||||
groupData = { groupName: "", memberIds: [] };
|
||||
groupMemberMap.set(groupId, groupData);
|
||||
}
|
||||
// Use orgUser.id (organization user ID) to match collection assignments and email map
|
||||
@@ -804,13 +806,22 @@ export class CipherAccessMappingService {
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Fetching organization users (single API call) for ${organizationId}`,
|
||||
`[CipherAccessMappingService] Fetching organization users and groups for ${organizationId}`,
|
||||
);
|
||||
|
||||
// Single API call with includeGroups: true - gets all user data needed
|
||||
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
|
||||
includeGroups: true,
|
||||
});
|
||||
// Fetch users and groups in parallel
|
||||
const [orgUsersResponse, groupsResponse] = await Promise.all([
|
||||
this.organizationUserApiService.getAllUsers(organizationId, {
|
||||
includeGroups: true,
|
||||
}),
|
||||
this.fetchGroupNames(organizationId),
|
||||
]);
|
||||
|
||||
// Build group name lookup map
|
||||
const groupNameMap = new Map<string, string>();
|
||||
for (const group of groupsResponse) {
|
||||
groupNameMap.set(group.id, group.name);
|
||||
}
|
||||
|
||||
// Build both maps from the same response
|
||||
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
|
||||
@@ -827,7 +838,9 @@ export class CipherAccessMappingService {
|
||||
for (const groupId of orgUser.groups) {
|
||||
let groupData = groupMemberMap.get(groupId);
|
||||
if (!groupData) {
|
||||
groupData = { groupName: "Unknown Group", memberIds: [] };
|
||||
// Look up the actual group name
|
||||
const groupName = groupNameMap.get(groupId) ?? "";
|
||||
groupData = { groupName, memberIds: [] };
|
||||
groupMemberMap.set(groupId, groupData);
|
||||
}
|
||||
groupData.memberIds.push(orgUser.id);
|
||||
@@ -988,4 +1001,40 @@ export class CipherAccessMappingService {
|
||||
memberAccess.effectivePermissions.canManage = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all group names for an organization.
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @returns Array of groups with id and name
|
||||
*/
|
||||
private async fetchGroupNames(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/groups`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
// The response is a ListResponse with data array containing group objects
|
||||
if (response?.data && Array.isArray(response.data)) {
|
||||
return response.data.map((g: { id: string; name: string }) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
this.logService.warning(
|
||||
`[CipherAccessMappingService] Failed to fetch group names for org ${organizationId}`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
EffectivePermissionLevel,
|
||||
MemberAccessReportState,
|
||||
MemberAccessSummary,
|
||||
} from "../../models/member-access-report.types";
|
||||
|
||||
import {
|
||||
CipherAccessMappingService,
|
||||
CipherMemberAccess,
|
||||
CipherWithMemberAccess,
|
||||
MemberAccessLoadState,
|
||||
CipherAccessMappingProgressiveResult,
|
||||
} from "./cipher-access-mapping.service";
|
||||
import { MemberAccessReportService } from "./member-access-report.service";
|
||||
|
||||
const TestOrgId = "test-org-id" as OrganizationId;
|
||||
const TestUserId = "test-user-id" as UserId;
|
||||
|
||||
describe("MemberAccessReportService", () => {
|
||||
let service: MemberAccessReportService;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let cipherAccessMappingService: MockProxy<CipherAccessMappingService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
cipherService = mock<CipherService>();
|
||||
cipherAccessMappingService = mock<CipherAccessMappingService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Create service with constructor injection
|
||||
service = new MemberAccessReportService(cipherService, cipherAccessMappingService, logService);
|
||||
});
|
||||
|
||||
describe("pivotCipherDataToMemberSummaries", () => {
|
||||
it("should return empty array for empty input", () => {
|
||||
const result = service.pivotCipherDataToMemberSummaries([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle single member with direct collection access", () => {
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual<MemberAccessSummary>({
|
||||
userId: "user-1",
|
||||
email: "user1@test.com",
|
||||
name: null,
|
||||
cipherCount: 1,
|
||||
collectionCount: 1,
|
||||
groupCount: 0,
|
||||
highestPermission: EffectivePermissionLevel.Edit,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle member with group-based access", () => {
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "group",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
groupId: "group-1",
|
||||
groupName: "Group 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual<MemberAccessSummary>({
|
||||
userId: "user-1",
|
||||
email: "user1@test.com",
|
||||
name: null,
|
||||
cipherCount: 1,
|
||||
collectionCount: 1,
|
||||
groupCount: 1,
|
||||
highestPermission: EffectivePermissionLevel.Edit,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle member with multiple access paths and aggregate permissions", () => {
|
||||
// Same member accessing multiple ciphers through different paths
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess(
|
||||
"user-1",
|
||||
"user1@test.com",
|
||||
[
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: true, hidePasswords: false, manage: false },
|
||||
},
|
||||
],
|
||||
{ canEdit: false, canViewPasswords: true, canManage: false },
|
||||
),
|
||||
]),
|
||||
createCipherWithMembers("cipher-2", [
|
||||
createMemberAccess(
|
||||
"user-1",
|
||||
"user1@test.com",
|
||||
[
|
||||
{
|
||||
type: "group",
|
||||
collectionId: "col-2",
|
||||
collectionName: "Collection 2",
|
||||
groupId: "group-1",
|
||||
groupName: "Group 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: true },
|
||||
},
|
||||
],
|
||||
{ canEdit: true, canViewPasswords: true, canManage: true },
|
||||
),
|
||||
]),
|
||||
createCipherWithMembers("cipher-3", [
|
||||
createMemberAccess(
|
||||
"user-1",
|
||||
"user1@test.com",
|
||||
[
|
||||
{
|
||||
type: "group",
|
||||
collectionId: "col-3",
|
||||
collectionName: "Collection 3",
|
||||
groupId: "group-2",
|
||||
groupName: "Group 2",
|
||||
permissions: { readOnly: true, hidePasswords: true, manage: false },
|
||||
},
|
||||
],
|
||||
{ canEdit: false, canViewPasswords: false, canManage: false },
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual<MemberAccessSummary>({
|
||||
userId: "user-1",
|
||||
email: "user1@test.com",
|
||||
name: null,
|
||||
cipherCount: 3,
|
||||
collectionCount: 3,
|
||||
groupCount: 2,
|
||||
// Should be Manage because one path has manage permission
|
||||
highestPermission: EffectivePermissionLevel.Manage,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple members with different permission levels", () => {
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess(
|
||||
"user-1",
|
||||
"user1@test.com",
|
||||
[
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: true },
|
||||
},
|
||||
],
|
||||
{ canEdit: true, canViewPasswords: true, canManage: true },
|
||||
),
|
||||
createMemberAccess(
|
||||
"user-2",
|
||||
"user2@test.com",
|
||||
[
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: true, hidePasswords: false, manage: false },
|
||||
},
|
||||
],
|
||||
{ canEdit: false, canViewPasswords: true, canManage: false },
|
||||
),
|
||||
createMemberAccess(
|
||||
"user-3",
|
||||
"user3@test.com",
|
||||
[
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: true, hidePasswords: true, manage: false },
|
||||
},
|
||||
],
|
||||
{ canEdit: false, canViewPasswords: false, canManage: false },
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
// Find each member in results
|
||||
const user1 = result.find((m) => m.userId === "user-1");
|
||||
const user2 = result.find((m) => m.userId === "user-2");
|
||||
const user3 = result.find((m) => m.userId === "user-3");
|
||||
|
||||
expect(user1?.highestPermission).toBe(EffectivePermissionLevel.Manage);
|
||||
expect(user2?.highestPermission).toBe(EffectivePermissionLevel.ViewOnly);
|
||||
expect(user3?.highestPermission).toBe(EffectivePermissionLevel.HidePasswords);
|
||||
});
|
||||
|
||||
it("should deduplicate collections and groups across multiple ciphers", () => {
|
||||
// Same member accessing multiple ciphers through the same collection/group
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "group",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
groupId: "group-1",
|
||||
groupName: "Group 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
createCipherWithMembers("cipher-2", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "group",
|
||||
collectionId: "col-1", // Same collection
|
||||
collectionName: "Collection 1",
|
||||
groupId: "group-1", // Same group
|
||||
groupName: "Group 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
createCipherWithMembers("cipher-3", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1", // Same collection, direct access
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].cipherCount).toBe(3); // 3 unique ciphers
|
||||
expect(result[0].collectionCount).toBe(1); // Only 1 unique collection
|
||||
expect(result[0].groupCount).toBe(1); // Only 1 unique group
|
||||
});
|
||||
|
||||
it("should sort members by cipher count descending, then email ascending", () => {
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-a", "alice@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
createMemberAccess("user-b", "bob@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
createCipherWithMembers("cipher-2", [
|
||||
createMemberAccess("user-b", "bob@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-2",
|
||||
collectionName: "Collection 2",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
createCipherWithMembers("cipher-3", [
|
||||
createMemberAccess("user-b", "bob@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-3",
|
||||
collectionName: "Collection 3",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
createMemberAccess("user-c", "charlie@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-3",
|
||||
collectionName: "Collection 3",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// Bob has 3 ciphers, Alice and Charlie have 1 each
|
||||
expect(result[0].email).toBe("bob@test.com");
|
||||
expect(result[0].cipherCount).toBe(3);
|
||||
// Alice comes before Charlie alphabetically (both have 1 cipher)
|
||||
expect(result[1].email).toBe("alice@test.com");
|
||||
expect(result[1].cipherCount).toBe(1);
|
||||
expect(result[2].email).toBe("charlie@test.com");
|
||||
expect(result[2].cipherCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle member with null email", () => {
|
||||
const ciphersWithMembers: CipherWithMemberAccess[] = [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", null, [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].email).toBe("(unknown)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMemberAccessSummariesProgressive$", () => {
|
||||
it("should return empty result for organization with no ciphers", (done) => {
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([]);
|
||||
|
||||
const results: MemberAccessReportState[] = [];
|
||||
|
||||
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
|
||||
next: (result) => {
|
||||
results.push(result.state);
|
||||
if (result.state === MemberAccessReportState.Complete) {
|
||||
expect(result.members).toEqual([]);
|
||||
expect(result.processedCipherCount).toBe(0);
|
||||
expect(result.totalCipherCount).toBe(0);
|
||||
expect(result.progressPercent).toBe(100);
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit progressive results as batches complete", (done) => {
|
||||
const mockCiphers = [createMockCipherView("cipher-1"), createMockCipherView("cipher-2")];
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
|
||||
|
||||
// Mock progressive results with two batches
|
||||
const progressiveResults$ = new BehaviorSubject<CipherAccessMappingProgressiveResult>({
|
||||
state: MemberAccessLoadState.ProcessingBatches,
|
||||
processedCiphers: [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
],
|
||||
totalCipherCount: 2,
|
||||
processedCount: 1,
|
||||
progressPercent: 50,
|
||||
timings: {},
|
||||
counts: {},
|
||||
});
|
||||
|
||||
cipherAccessMappingService.getAllCiphersWithMemberAccessProgressive$.mockReturnValue(
|
||||
progressiveResults$,
|
||||
);
|
||||
|
||||
const results: MemberAccessReportState[] = [];
|
||||
|
||||
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
|
||||
next: (result) => {
|
||||
results.push(result.state);
|
||||
|
||||
if (result.state === MemberAccessReportState.ProcessingMembers) {
|
||||
// Emit the final batch
|
||||
progressiveResults$.next({
|
||||
state: MemberAccessLoadState.Complete,
|
||||
processedCiphers: [
|
||||
createCipherWithMembers("cipher-1", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-1",
|
||||
collectionName: "Collection 1",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
createCipherWithMembers("cipher-2", [
|
||||
createMemberAccess("user-1", "user1@test.com", [
|
||||
{
|
||||
type: "direct",
|
||||
collectionId: "col-2",
|
||||
collectionName: "Collection 2",
|
||||
permissions: { readOnly: false, hidePasswords: false, manage: false },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
],
|
||||
totalCipherCount: 2,
|
||||
processedCount: 2,
|
||||
progressPercent: 100,
|
||||
timings: {},
|
||||
counts: {},
|
||||
});
|
||||
progressiveResults$.complete();
|
||||
}
|
||||
|
||||
if (result.state === MemberAccessReportState.Complete) {
|
||||
expect(result.members).toHaveLength(1);
|
||||
expect(result.members[0].cipherCount).toBe(2);
|
||||
expect(result.progressPercent).toBe(100);
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors and emit error state", (done) => {
|
||||
cipherService.getAllFromApiForOrganization.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
|
||||
next: (result) => {
|
||||
if (result.state === MemberAccessReportState.Error) {
|
||||
expect(result.error).toBe("API Error");
|
||||
expect(result.members).toEqual([]);
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
function createMockCipherView(id: string): CipherView {
|
||||
const cipher = new CipherView();
|
||||
cipher.id = id;
|
||||
cipher.name = `Cipher ${id}`;
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function createMemberAccess(
|
||||
userId: string,
|
||||
email: string | null,
|
||||
accessPaths: CipherMemberAccess["accessPaths"],
|
||||
effectivePermissions?: CipherMemberAccess["effectivePermissions"],
|
||||
): CipherMemberAccess {
|
||||
// Calculate default effective permissions from access paths if not provided
|
||||
const defaultPermissions = effectivePermissions ?? {
|
||||
canEdit: accessPaths.some((p) => !p.permissions.readOnly),
|
||||
canViewPasswords: accessPaths.some((p) => !p.permissions.hidePasswords),
|
||||
canManage: accessPaths.some((p) => p.permissions.manage),
|
||||
};
|
||||
|
||||
return {
|
||||
userId,
|
||||
email,
|
||||
accessPaths,
|
||||
effectivePermissions: defaultPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
function createCipherWithMembers(
|
||||
cipherId: string,
|
||||
members: CipherMemberAccess[],
|
||||
): CipherWithMemberAccess {
|
||||
const cipher = createMockCipherView(cipherId);
|
||||
return {
|
||||
cipher,
|
||||
members,
|
||||
totalMemberCount: members.length,
|
||||
unassigned: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Observable, catchError, from, map, of, switchMap, tap } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import {
|
||||
EffectivePermissionLevel,
|
||||
MemberAccessDetailView,
|
||||
MemberAccessReportProgressiveResult,
|
||||
MemberAccessReportState,
|
||||
MemberAccessSummary,
|
||||
MemberCollectionAccessDetail,
|
||||
} from "../../models/member-access-report.types";
|
||||
|
||||
import {
|
||||
CipherAccessMappingService,
|
||||
CipherAccessPath,
|
||||
CipherMemberAccess,
|
||||
CipherWithMemberAccess,
|
||||
EffectiveCipherPermissions,
|
||||
MemberAccessLoadState,
|
||||
} from "./cipher-access-mapping.service";
|
||||
|
||||
/**
|
||||
* Internal accumulator for building member summaries as cipher batches arrive.
|
||||
* Tracks sets for deduplication and accumulates counts.
|
||||
*/
|
||||
interface MemberAccumulator {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
cipherIds: Set<string>;
|
||||
collectionIds: Set<string>;
|
||||
groupIds: Set<string>;
|
||||
/** Tracks if member has at least one manage permission */
|
||||
hasManage: boolean;
|
||||
/** Tracks if member has at least one edit (non-readOnly) permission */
|
||||
hasEdit: boolean;
|
||||
/** Tracks if member can view passwords (at least one non-hidePasswords) */
|
||||
hasViewPasswords: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for generating member-centric access reports from cipher data.
|
||||
*
|
||||
* This service provides a faster alternative to the server-side member access report
|
||||
* by leveraging the client-side CipherAccessMappingService infrastructure.
|
||||
*
|
||||
* The core algorithm pivots cipher-centric data (cipher -> members[]) to
|
||||
* member-centric data (member -> {cipherCount, collectionCount, groupCount, permission}).
|
||||
*
|
||||
* Use Cases:
|
||||
* - Faster member access reports using client-side data
|
||||
* - Progressive loading for large organizations
|
||||
* - Auditing member access across the organization
|
||||
*/
|
||||
@Injectable()
|
||||
export class MemberAccessReportService {
|
||||
constructor(
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly cipherAccessMappingService: CipherAccessMappingService,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generates member access summaries with progressive loading.
|
||||
* Emits partial results as cipher batches are processed.
|
||||
*
|
||||
* @param organizationId - The organization to generate the report for
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @returns Observable that emits progressive results after each batch
|
||||
*/
|
||||
getMemberAccessSummariesProgressive$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<MemberAccessReportProgressiveResult> {
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] Starting progressive member access report for org ${organizationId}`,
|
||||
);
|
||||
|
||||
// Phase 1: Fetch all ciphers
|
||||
return from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
|
||||
tap((ciphers) => {
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] Fetched ${ciphers.length} ciphers for org ${organizationId}`,
|
||||
);
|
||||
}),
|
||||
// Phase 2: Stream cipher-member mapping and pivot to member summaries
|
||||
switchMap((ciphers) => {
|
||||
if (ciphers.length === 0) {
|
||||
// No ciphers, return empty result immediately
|
||||
return of<MemberAccessReportProgressiveResult>({
|
||||
state: MemberAccessReportState.Complete,
|
||||
members: [],
|
||||
processedCipherCount: 0,
|
||||
totalCipherCount: 0,
|
||||
progressPercent: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulator persists across batches to build complete member data
|
||||
const memberAccumulators = new Map<string, MemberAccumulator>();
|
||||
|
||||
return this.cipherAccessMappingService
|
||||
.getAllCiphersWithMemberAccessProgressive$(organizationId, currentUserId, ciphers, 500)
|
||||
.pipe(
|
||||
map((progressResult) => {
|
||||
// Process the latest batch of ciphers into member accumulators
|
||||
this.processCipherBatchIntoAccumulators(
|
||||
progressResult.processedCiphers,
|
||||
memberAccumulators,
|
||||
);
|
||||
|
||||
// Convert accumulators to summaries
|
||||
const members = this.convertAccumulatorsToSummaries(memberAccumulators);
|
||||
|
||||
// Map states
|
||||
let state: MemberAccessReportState;
|
||||
if (progressResult.state === MemberAccessLoadState.Complete) {
|
||||
state = MemberAccessReportState.Complete;
|
||||
} else if (progressResult.state === MemberAccessLoadState.Error) {
|
||||
state = MemberAccessReportState.Error;
|
||||
} else {
|
||||
state = MemberAccessReportState.ProcessingMembers;
|
||||
}
|
||||
|
||||
const result: MemberAccessReportProgressiveResult = {
|
||||
state,
|
||||
members,
|
||||
processedCipherCount: progressResult.processedCount,
|
||||
totalCipherCount: progressResult.totalCipherCount,
|
||||
progressPercent: progressResult.progressPercent,
|
||||
error: progressResult.error,
|
||||
};
|
||||
|
||||
if (state === MemberAccessReportState.Complete) {
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] Report complete: ${members.length} members across ${progressResult.totalCipherCount} ciphers`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("[MemberAccessReportService] Error generating report", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error occurred during member access report generation";
|
||||
|
||||
return of<MemberAccessReportProgressiveResult>({
|
||||
state: MemberAccessReportState.Error,
|
||||
members: [],
|
||||
processedCipherCount: 0,
|
||||
totalCipherCount: 0,
|
||||
progressPercent: 0,
|
||||
error: errorMessage,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates member access summaries (non-progressive, complete result only).
|
||||
* Waits for all cipher batches to complete before returning.
|
||||
*
|
||||
* @param organizationId - The organization to generate the report for
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @returns Observable that emits the complete member summary list
|
||||
*/
|
||||
getMemberAccessSummaries$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<MemberAccessSummary[]> {
|
||||
return this.getMemberAccessSummariesProgressive$(organizationId, currentUserId).pipe(
|
||||
// Only emit when complete
|
||||
map((result) => {
|
||||
if (result.state === MemberAccessReportState.Complete) {
|
||||
return result.members;
|
||||
}
|
||||
// Return empty array for intermediate states (will be filtered by last())
|
||||
return [];
|
||||
}),
|
||||
// Filter to only get the final complete result
|
||||
map((members, index) => {
|
||||
// We rely on the final emission from the progressive stream
|
||||
return members;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves detailed access information for a specific member.
|
||||
* Returns collection-level breakdown with cipher counts and permissions.
|
||||
*
|
||||
* @param organizationId - The organization to query
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @param targetUserId - The member's user ID to get details for
|
||||
* @returns Observable that emits the detail view, or null if member not found
|
||||
*/
|
||||
getMemberAccessDetail$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
targetUserId: string,
|
||||
): Observable<MemberAccessDetailView | null> {
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] Getting access detail for user ${targetUserId} in org ${organizationId}`,
|
||||
);
|
||||
|
||||
return this.cipherAccessMappingService
|
||||
.findCiphersForUser$(organizationId, currentUserId, targetUserId)
|
||||
.pipe(
|
||||
map((userCiphers) => {
|
||||
if (userCiphers.length === 0) {
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] No ciphers found for user ${targetUserId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.transformToDetailView(userCiphers, targetUserId);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
`[MemberAccessReportService] Error getting member detail for ${targetUserId}`,
|
||||
error,
|
||||
);
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots an array of cipher-member data to member summaries.
|
||||
* This is the core transformation logic, exposed for direct use or testing.
|
||||
*
|
||||
* @param ciphersWithMembers - Array of ciphers with their member access data
|
||||
* @returns Array of member access summaries
|
||||
*/
|
||||
pivotCipherDataToMemberSummaries(
|
||||
ciphersWithMembers: CipherWithMemberAccess[],
|
||||
): MemberAccessSummary[] {
|
||||
const memberAccumulators = new Map<string, MemberAccumulator>();
|
||||
this.processCipherBatchIntoAccumulators(ciphersWithMembers, memberAccumulators);
|
||||
return this.convertAccumulatorsToSummaries(memberAccumulators);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Processes a batch of ciphers with member access data into the accumulator map.
|
||||
* Updates existing accumulators or creates new ones as needed.
|
||||
*
|
||||
* @param ciphersWithMembers - Batch of ciphers with member access
|
||||
* @param accumulators - Map to accumulate member data into
|
||||
*/
|
||||
private processCipherBatchIntoAccumulators(
|
||||
ciphersWithMembers: CipherWithMemberAccess[],
|
||||
accumulators: Map<string, MemberAccumulator>,
|
||||
): void {
|
||||
for (const cipherData of ciphersWithMembers) {
|
||||
const cipherId = cipherData.cipher.id;
|
||||
|
||||
for (const memberAccess of cipherData.members) {
|
||||
this.updateAccumulatorFromMemberAccess(accumulators, cipherId, memberAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates an accumulator for a member based on their access to a cipher.
|
||||
*
|
||||
* @param accumulators - The accumulator map
|
||||
* @param cipherId - The cipher ID being processed
|
||||
* @param memberAccess - The member's access data for this cipher
|
||||
*/
|
||||
private updateAccumulatorFromMemberAccess(
|
||||
accumulators: Map<string, MemberAccumulator>,
|
||||
cipherId: string,
|
||||
memberAccess: CipherMemberAccess,
|
||||
): void {
|
||||
const userId = memberAccess.userId;
|
||||
|
||||
// Get or create accumulator
|
||||
let accumulator = accumulators.get(userId);
|
||||
if (!accumulator) {
|
||||
accumulator = {
|
||||
userId,
|
||||
email: memberAccess.email ?? "(unknown)",
|
||||
name: null, // Name is not available in CipherMemberAccess
|
||||
cipherIds: new Set<string>(),
|
||||
collectionIds: new Set<string>(),
|
||||
groupIds: new Set<string>(),
|
||||
hasManage: false,
|
||||
hasEdit: false,
|
||||
hasViewPasswords: false,
|
||||
};
|
||||
accumulators.set(userId, accumulator);
|
||||
}
|
||||
|
||||
// Add cipher to the set (deduplication)
|
||||
accumulator.cipherIds.add(cipherId);
|
||||
|
||||
// Process access paths to collect collections and groups
|
||||
for (const accessPath of memberAccess.accessPaths) {
|
||||
this.processAccessPath(accumulator, accessPath);
|
||||
}
|
||||
|
||||
// Update permission flags from effective permissions
|
||||
this.updatePermissionFlags(accumulator, memberAccess.effectivePermissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single access path to extract collection and group IDs.
|
||||
*
|
||||
* @param accumulator - The member accumulator to update
|
||||
* @param accessPath - The access path to process
|
||||
*/
|
||||
private processAccessPath(accumulator: MemberAccumulator, accessPath: CipherAccessPath): void {
|
||||
// Always add collection
|
||||
accumulator.collectionIds.add(accessPath.collectionId);
|
||||
|
||||
// Add group if this is a group-based access path
|
||||
if (accessPath.type === "group" && accessPath.groupId) {
|
||||
accumulator.groupIds.add(accessPath.groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates permission flags based on effective permissions.
|
||||
* Uses "most permissive" logic - if any path grants a permission, it's tracked.
|
||||
*
|
||||
* @param accumulator - The member accumulator to update
|
||||
* @param permissions - The effective permissions for this cipher access
|
||||
*/
|
||||
private updatePermissionFlags(
|
||||
accumulator: MemberAccumulator,
|
||||
permissions: EffectiveCipherPermissions,
|
||||
): void {
|
||||
if (permissions.canManage) {
|
||||
accumulator.hasManage = true;
|
||||
}
|
||||
if (permissions.canEdit) {
|
||||
accumulator.hasEdit = true;
|
||||
}
|
||||
if (permissions.canViewPasswords) {
|
||||
accumulator.hasViewPasswords = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts accumulator map to sorted array of member summaries.
|
||||
*
|
||||
* @param accumulators - Map of member accumulators
|
||||
* @returns Sorted array of member access summaries
|
||||
*/
|
||||
private convertAccumulatorsToSummaries(
|
||||
accumulators: Map<string, MemberAccumulator>,
|
||||
): MemberAccessSummary[] {
|
||||
const summaries: MemberAccessSummary[] = [];
|
||||
|
||||
for (const accumulator of accumulators.values()) {
|
||||
summaries.push({
|
||||
userId: accumulator.userId,
|
||||
email: accumulator.email,
|
||||
name: accumulator.name,
|
||||
cipherCount: accumulator.cipherIds.size,
|
||||
collectionCount: accumulator.collectionIds.size,
|
||||
groupCount: accumulator.groupIds.size,
|
||||
highestPermission: this.calculateHighestPermission(accumulator),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by cipher count descending, then by email ascending
|
||||
summaries.sort((a, b) => {
|
||||
if (b.cipherCount !== a.cipherCount) {
|
||||
return b.cipherCount - a.cipherCount;
|
||||
}
|
||||
return a.email.localeCompare(b.email);
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the highest permission level from accumulated flags.
|
||||
*
|
||||
* Permission hierarchy (highest to lowest):
|
||||
* 1. Manage - Full control including delete
|
||||
* 2. Edit - Can modify but not manage
|
||||
* 3. ViewOnly - Can view but not modify (readOnly: true, hidePasswords: false)
|
||||
* 4. HidePasswords - Most restricted (readOnly: true, hidePasswords: true)
|
||||
*
|
||||
* @param accumulator - The member accumulator with permission flags
|
||||
* @returns The highest effective permission level
|
||||
*/
|
||||
private calculateHighestPermission(accumulator: MemberAccumulator): EffectivePermissionLevel {
|
||||
// Check in order of priority (highest to lowest)
|
||||
if (accumulator.hasManage) {
|
||||
return EffectivePermissionLevel.Manage;
|
||||
}
|
||||
if (accumulator.hasEdit) {
|
||||
return EffectivePermissionLevel.Edit;
|
||||
}
|
||||
if (accumulator.hasViewPasswords) {
|
||||
return EffectivePermissionLevel.ViewOnly;
|
||||
}
|
||||
// Default to most restricted if none of the above
|
||||
return EffectivePermissionLevel.HidePasswords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms cipher access data into a member detail view.
|
||||
* Groups ciphers by collection+accessType+groupId to create access path summaries.
|
||||
*
|
||||
* @param userCiphers - Array of ciphers the user has access to
|
||||
* @param targetUserId - The user ID to extract details for
|
||||
* @returns MemberAccessDetailView with collection breakdown
|
||||
*/
|
||||
private transformToDetailView(
|
||||
userCiphers: CipherWithMemberAccess[],
|
||||
targetUserId: string,
|
||||
): MemberAccessDetailView {
|
||||
// Extract user info from first cipher's member data
|
||||
let email = "(unknown)";
|
||||
const name: string | null = null;
|
||||
|
||||
for (const cipherData of userCiphers) {
|
||||
const memberData = cipherData.members.find((m) => m.userId === targetUserId);
|
||||
if (memberData?.email) {
|
||||
email = memberData.email;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map keyed by "collectionId|accessType|groupId" to group ciphers by access path
|
||||
const accessPathMap = new Map<
|
||||
string,
|
||||
{
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
accessType: "direct" | "group";
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
permissions: { readOnly: boolean; hidePasswords: boolean; manage: boolean };
|
||||
cipherIds: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Process each cipher and its access paths for this user
|
||||
for (const cipherData of userCiphers) {
|
||||
const memberData = cipherData.members.find((m) => m.userId === targetUserId);
|
||||
if (!memberData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const accessPath of memberData.accessPaths) {
|
||||
const groupId = accessPath.type === "group" ? (accessPath.groupId ?? null) : null;
|
||||
const key = `${accessPath.collectionId}|${accessPath.type}|${groupId ?? ""}`;
|
||||
|
||||
let pathData = accessPathMap.get(key);
|
||||
if (!pathData) {
|
||||
pathData = {
|
||||
collectionId: accessPath.collectionId,
|
||||
collectionName: accessPath.collectionName,
|
||||
accessType: accessPath.type,
|
||||
groupId: groupId,
|
||||
groupName: accessPath.type === "group" ? (accessPath.groupName ?? null) : null,
|
||||
permissions: { ...accessPath.permissions },
|
||||
cipherIds: new Set<string>(),
|
||||
};
|
||||
accessPathMap.set(key, pathData);
|
||||
}
|
||||
|
||||
pathData.cipherIds.add(cipherData.cipher.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to MemberCollectionAccessDetail array
|
||||
const collectionDetails: MemberCollectionAccessDetail[] = [];
|
||||
for (const pathData of accessPathMap.values()) {
|
||||
collectionDetails.push({
|
||||
collectionId: pathData.collectionId,
|
||||
collectionName: pathData.collectionName,
|
||||
cipherCount: pathData.cipherIds.size,
|
||||
permission: this.calculatePermissionFromFlags(pathData.permissions),
|
||||
accessType: pathData.accessType,
|
||||
groupName: pathData.groupName,
|
||||
groupId: pathData.groupId,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by collection name, then by access type (direct first)
|
||||
collectionDetails.sort((a, b) => {
|
||||
const nameCompare = a.collectionName.localeCompare(b.collectionName);
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
// Direct access before group access
|
||||
if (a.accessType !== b.accessType) {
|
||||
return a.accessType === "direct" ? -1 : 1;
|
||||
}
|
||||
// If both are group access, sort by group name
|
||||
if (a.groupName && b.groupName) {
|
||||
return a.groupName.localeCompare(b.groupName);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Count unique ciphers
|
||||
const uniqueCipherIds = new Set<string>();
|
||||
for (const cipherData of userCiphers) {
|
||||
uniqueCipherIds.add(cipherData.cipher.id);
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[MemberAccessReportService] Built detail view for ${email}: ${uniqueCipherIds.size} ciphers, ${collectionDetails.length} access paths`,
|
||||
);
|
||||
|
||||
return {
|
||||
userId: targetUserId,
|
||||
email,
|
||||
name,
|
||||
totalCipherCount: uniqueCipherIds.size,
|
||||
collectionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates permission level from raw permission flags.
|
||||
*
|
||||
* @param permissions - Raw permission flags from access path
|
||||
* @returns The effective permission level
|
||||
*/
|
||||
private calculatePermissionFromFlags(permissions: {
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
}): EffectivePermissionLevel {
|
||||
if (permissions.manage) {
|
||||
return EffectivePermissionLevel.Manage;
|
||||
}
|
||||
if (!permissions.readOnly) {
|
||||
return EffectivePermissionLevel.Edit;
|
||||
}
|
||||
if (!permissions.hidePasswords) {
|
||||
return EffectivePermissionLevel.ViewOnly;
|
||||
}
|
||||
return EffectivePermissionLevel.HidePasswords;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from "./api/risk-insights-api.service";
|
||||
export * from "./api/security-tasks-api.service";
|
||||
export * from "./domain/cipher-access-mapping.service";
|
||||
export * from "./domain/critical-apps.service";
|
||||
export * from "./domain/member-access-report.service";
|
||||
export * from "./domain/password-health.service";
|
||||
export * from "./domain/risk-insights-encryption.service";
|
||||
export * from "./domain/risk-insights-orchestrator.service";
|
||||
|
||||
@@ -78,6 +78,17 @@ const routes: Routes = [
|
||||
},
|
||||
canActivate: [isEnterpriseOrgGuard()],
|
||||
},
|
||||
{
|
||||
path: "member-access-report-prototype",
|
||||
loadComponent: () =>
|
||||
import("../../dirt/reports/member-access-report-prototype/member-access-report-prototype.component").then(
|
||||
(mod) => mod.MemberAccessReportPrototypeComponent,
|
||||
),
|
||||
data: {
|
||||
titleId: "memberAccessReport",
|
||||
},
|
||||
canActivate: [isEnterpriseOrgGuard()],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<bit-dialog title="Member Access Details">
|
||||
<div bitDialogContent>
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-justify-center tw-items-center tw-py-8">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
|
||||
<span class="tw-ml-2">Loading access details...</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading && !detailView">
|
||||
<div class="tw-text-center tw-py-8 tw-text-muted">
|
||||
No access details found for this member.
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading && detailView">
|
||||
<!-- Member Info -->
|
||||
<div class="tw-mb-4">
|
||||
<p bitTypography="body1" class="tw-font-semibold tw-mb-1">
|
||||
{{ detailView.name || detailView.email }}
|
||||
</p>
|
||||
<p bitTypography="body2" class="tw-text-muted" *ngIf="detailView.name">
|
||||
{{ detailView.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="tw-mb-4 tw-p-3 tw-bg-background-alt tw-rounded">
|
||||
<p bitTypography="body2">
|
||||
<strong>{{ detailView.totalCipherCount }}</strong> items accessible via
|
||||
<strong>{{ detailView.collectionDetails.length }}</strong> access paths
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Access Details Table -->
|
||||
<div class="tw-max-h-[400px] tw-overflow-y-auto">
|
||||
<table class="tw-w-full tw-text-sm">
|
||||
<thead class="tw-bg-background-alt tw-sticky tw-top-0">
|
||||
<tr>
|
||||
<th class="tw-text-left tw-p-2 tw-font-medium">Collection</th>
|
||||
<th class="tw-text-left tw-p-2 tw-font-medium">Access Via</th>
|
||||
<th class="tw-text-right tw-p-2 tw-font-medium tw-w-[80px]">Items</th>
|
||||
<th class="tw-text-left tw-p-2 tw-font-medium tw-w-[120px]">Permission</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let detail of detailView.collectionDetails"
|
||||
class="tw-border-b tw-border-secondary-300"
|
||||
>
|
||||
<td class="tw-p-2">{{ detail.collectionName }}</td>
|
||||
<td class="tw-p-2 tw-text-muted">
|
||||
<ng-container *ngIf="detail.accessType === 'direct'"> Direct </ng-container>
|
||||
<ng-container *ngIf="detail.accessType === 'group'">
|
||||
via {{ detail.groupName || "Group" }}
|
||||
</ng-container>
|
||||
</td>
|
||||
<td class="tw-p-2 tw-text-right">{{ detail.cipherCount }}</td>
|
||||
<td class="tw-p-2">
|
||||
<span bitBadge [variant]="getPermissionBadgeVariant(detail.permission)">
|
||||
{{ getPermissionLabel(detail.permission) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>Close</button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
Inject,
|
||||
OnInit,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
|
||||
import {
|
||||
CipherAccessMappingService,
|
||||
EffectivePermissionLevel,
|
||||
MemberAccessDetailView,
|
||||
MemberAccessReportService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DIALOG_DATA,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface MemberAccessDetailDialogData {
|
||||
organizationId: OrganizationId;
|
||||
currentUserId: UserId;
|
||||
targetUserId: string;
|
||||
memberEmail: string;
|
||||
memberName: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-member-access-detail-dialog",
|
||||
templateUrl: "member-access-detail-dialog.component.html",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, DialogModule, ButtonModule, BadgeModule, TypographyModule],
|
||||
providers: [CipherAccessMappingService, MemberAccessReportService],
|
||||
})
|
||||
export class MemberAccessDetailDialogComponent implements OnInit {
|
||||
protected loading = true;
|
||||
protected detailView: MemberAccessDetailView | null = null;
|
||||
|
||||
protected readonly EffectivePermissionLevel = EffectivePermissionLevel;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected dialogData: MemberAccessDetailDialogData,
|
||||
private readonly memberAccessReportService: MemberAccessReportService,
|
||||
private readonly changeDetectorRef: ChangeDetectorRef,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.memberAccessReportService
|
||||
.getMemberAccessDetail$(
|
||||
this.dialogData.organizationId,
|
||||
this.dialogData.currentUserId,
|
||||
this.dialogData.targetUserId,
|
||||
)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (detail) => {
|
||||
this.detailView = detail;
|
||||
this.loading = false;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, data: MemberAccessDetailDialogData) {
|
||||
return dialogService.open(MemberAccessDetailDialogComponent, { data });
|
||||
}
|
||||
|
||||
getPermissionBadgeVariant(
|
||||
permission: EffectivePermissionLevel,
|
||||
): "primary" | "secondary" | "success" | "danger" | "warning" | "info" {
|
||||
switch (permission) {
|
||||
case EffectivePermissionLevel.Manage:
|
||||
return "success";
|
||||
case EffectivePermissionLevel.Edit:
|
||||
return "primary";
|
||||
case EffectivePermissionLevel.ViewOnly:
|
||||
return "info";
|
||||
case EffectivePermissionLevel.HidePasswords:
|
||||
return "warning";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
getPermissionLabel(permission: EffectivePermissionLevel): string {
|
||||
switch (permission) {
|
||||
case EffectivePermissionLevel.Manage:
|
||||
return "Manage";
|
||||
case EffectivePermissionLevel.Edit:
|
||||
return "Edit";
|
||||
case EffectivePermissionLevel.ViewOnly:
|
||||
return "View Only";
|
||||
case EffectivePermissionLevel.HidePasswords:
|
||||
return "Hide Passwords";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<app-header>
|
||||
<bit-search
|
||||
[formControl]="searchControl"
|
||||
[placeholder]="'searchMembers' | i18n"
|
||||
class="tw-grow"
|
||||
*ngIf="isComplete"
|
||||
></bit-search>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
(click)="startLoading()"
|
||||
*ngIf="isComplete || isError"
|
||||
>
|
||||
<i class="bwi bwi-refresh" aria-hidden="true"></i>
|
||||
Reload
|
||||
</button>
|
||||
</app-header>
|
||||
|
||||
<div class="tw-max-w-4xl tw-mb-4">
|
||||
<p bitTypography="body1">
|
||||
<strong>Prototype:</strong> Member access report using client-side data assembly. This approach
|
||||
pivots cipher-access data to member-centric summaries without server-side API calls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<ng-container *ngIf="isLoading">
|
||||
<div class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-4 tw-my-8">
|
||||
<i class="bwi bwi-2x bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
|
||||
<h2 bitTypography="h2">
|
||||
<ng-container *ngIf="state === MemberAccessReportState.LoadingCiphers">
|
||||
Loading ciphers...
|
||||
</ng-container>
|
||||
<ng-container *ngIf="state === MemberAccessReportState.ProcessingMembers">
|
||||
Processing member access...
|
||||
</ng-container>
|
||||
</h2>
|
||||
|
||||
<div class="tw-w-full tw-max-w-md">
|
||||
<bit-progress [barWidth]="progressPercent"></bit-progress>
|
||||
<p class="tw-text-center tw-text-muted tw-mt-2">
|
||||
{{ processedCipherCount }} / {{ totalCipherCount }} ciphers processed ({{
|
||||
progressPercent
|
||||
}}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Error State -->
|
||||
<ng-container *ngIf="isError">
|
||||
<div
|
||||
class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-4 tw-mt-8 tw-p-4 tw-bg-danger-100 tw-rounded"
|
||||
>
|
||||
<i class="bwi bwi-2x bwi-error tw-text-danger-600" aria-hidden="true"></i>
|
||||
<h2 bitTypography="h2" class="tw-text-danger-600">Error Loading Report</h2>
|
||||
<p class="tw-text-danger-700">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container *ngIf="isComplete">
|
||||
<!-- Stats Bar -->
|
||||
<div class="tw-flex tw-gap-6 tw-mb-4 tw-p-4 tw-bg-background-alt tw-rounded tw-items-center">
|
||||
<div>
|
||||
<span class="tw-text-muted">Members:</span>
|
||||
<strong class="tw-ml-1">{{ dataSource.data.length }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw-text-muted">Ciphers Processed:</span>
|
||||
<strong class="tw-ml-1">{{ totalCipherCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw-text-muted">Load Time:</span>
|
||||
<strong class="tw-ml-1">{{ loadTimeMs }}ms</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="email" default>Member</th>
|
||||
<th bitCell bitSortable="cipherCount" class="tw-w-[150px]">Items</th>
|
||||
<th bitCell bitSortable="collectionCount" class="tw-w-[150px]">Collections</th>
|
||||
<th bitCell bitSortable="groupCount" class="tw-w-[150px]">Groups</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row let-rowAttr="attr">
|
||||
<td
|
||||
bitCell
|
||||
[attr.data-row-id]="rowAttr"
|
||||
class="tw-cursor-pointer"
|
||||
(click)="onMemberRowClick(row)"
|
||||
>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar size="small" [text]="row.email" class="tw-mr-3"></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<span class="tw-font-medium">{{ row.name || row.email }}</span>
|
||||
<span class="tw-text-sm tw-text-muted" *ngIf="row.name">{{ row.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
|
||||
{{ row.cipherCount }}
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
|
||||
{{ row.collectionCount }}
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
|
||||
{{ row.groupCount }}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,221 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, debounceTime, firstValueFrom, takeUntil } from "rxjs";
|
||||
|
||||
import {
|
||||
CipherAccessMappingService,
|
||||
EffectivePermissionLevel,
|
||||
MemberAccessReportService,
|
||||
MemberAccessReportState,
|
||||
MemberAccessSummary,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
BadgeModule,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ProgressModule,
|
||||
SearchModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { MemberAccessDetailDialogComponent } from "./member-access-detail-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-member-access-report-prototype",
|
||||
templateUrl: "member-access-report-prototype.component.html",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SearchModule,
|
||||
HeaderModule,
|
||||
TableModule,
|
||||
BadgeModule,
|
||||
IconModule,
|
||||
ProgressModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
providers: [CipherAccessMappingService, MemberAccessReportService],
|
||||
})
|
||||
export class MemberAccessReportPrototypeComponent implements OnInit, OnDestroy {
|
||||
protected dataSource = new TableDataSource<MemberAccessSummary>();
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
protected organizationId: OrganizationId;
|
||||
protected currentUserId: UserId;
|
||||
|
||||
// Loading state
|
||||
protected state: MemberAccessReportState = MemberAccessReportState.Idle;
|
||||
protected progressPercent = 0;
|
||||
protected processedCipherCount = 0;
|
||||
protected totalCipherCount = 0;
|
||||
protected errorMessage: string | null = null;
|
||||
|
||||
// Timing info
|
||||
protected loadStartTime: number = 0;
|
||||
protected loadEndTime: number = 0;
|
||||
|
||||
// Constants for template
|
||||
protected readonly MemberAccessReportState = MemberAccessReportState;
|
||||
protected readonly EffectivePermissionLevel = EffectivePermissionLevel;
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly memberAccessReportService: MemberAccessReportService,
|
||||
private readonly changeDetectorRef: ChangeDetectorRef,
|
||||
private readonly dialogService: DialogService,
|
||||
) {
|
||||
// Connect the search input to the table dataSource filter
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.subscribe((v) => (this.dataSource.filter = v));
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const params = await firstValueFrom(this.route.params);
|
||||
this.organizationId = params.organizationId;
|
||||
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (account) {
|
||||
this.currentUserId = account.id;
|
||||
}
|
||||
|
||||
// Auto-start loading
|
||||
this.startLoading();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
startLoading() {
|
||||
if (!this.organizationId || !this.currentUserId) {
|
||||
this.errorMessage = "Organization ID or User ID not available";
|
||||
this.state = MemberAccessReportState.Error;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.state = MemberAccessReportState.LoadingCiphers;
|
||||
this.progressPercent = 0;
|
||||
this.processedCipherCount = 0;
|
||||
this.totalCipherCount = 0;
|
||||
this.errorMessage = null;
|
||||
this.dataSource.data = [];
|
||||
this.loadStartTime = performance.now();
|
||||
this.loadEndTime = 0;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
this.memberAccessReportService
|
||||
.getMemberAccessSummariesProgressive$(this.organizationId, this.currentUserId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.state = result.state;
|
||||
this.progressPercent = result.progressPercent;
|
||||
this.processedCipherCount = result.processedCipherCount;
|
||||
this.totalCipherCount = result.totalCipherCount;
|
||||
this.dataSource.data = result.members;
|
||||
|
||||
if (result.state === MemberAccessReportState.Complete) {
|
||||
this.loadEndTime = performance.now();
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.errorMessage = result.error;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.state = MemberAccessReportState.Error;
|
||||
this.errorMessage = err instanceof Error ? err.message : "An error occurred";
|
||||
this.loadEndTime = performance.now();
|
||||
this.changeDetectorRef.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get loadTimeMs(): number {
|
||||
if (this.loadEndTime > 0 && this.loadStartTime > 0) {
|
||||
return Math.round(this.loadEndTime - this.loadStartTime);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return (
|
||||
this.state === MemberAccessReportState.LoadingCiphers ||
|
||||
this.state === MemberAccessReportState.ProcessingMembers
|
||||
);
|
||||
}
|
||||
|
||||
get isComplete(): boolean {
|
||||
return this.state === MemberAccessReportState.Complete;
|
||||
}
|
||||
|
||||
get isError(): boolean {
|
||||
return this.state === MemberAccessReportState.Error;
|
||||
}
|
||||
|
||||
getPermissionBadgeVariant(
|
||||
permission: EffectivePermissionLevel,
|
||||
): "primary" | "secondary" | "success" | "danger" | "warning" | "info" {
|
||||
switch (permission) {
|
||||
case EffectivePermissionLevel.Manage:
|
||||
return "success";
|
||||
case EffectivePermissionLevel.Edit:
|
||||
return "primary";
|
||||
case EffectivePermissionLevel.ViewOnly:
|
||||
return "info";
|
||||
case EffectivePermissionLevel.HidePasswords:
|
||||
return "warning";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
getPermissionLabel(permission: EffectivePermissionLevel): string {
|
||||
switch (permission) {
|
||||
case EffectivePermissionLevel.Manage:
|
||||
return "Manage";
|
||||
case EffectivePermissionLevel.Edit:
|
||||
return "Edit";
|
||||
case EffectivePermissionLevel.ViewOnly:
|
||||
return "View Only";
|
||||
case EffectivePermissionLevel.HidePasswords:
|
||||
return "Hide Passwords";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
onMemberRowClick(member: MemberAccessSummary) {
|
||||
MemberAccessDetailDialogComponent.open(this.dialogService, {
|
||||
organizationId: this.organizationId,
|
||||
currentUserId: this.currentUserId,
|
||||
targetUserId: member.userId,
|
||||
memberEmail: member.email,
|
||||
memberName: member.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user