mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[PM-27762] Activity Tab - Password Change Progress - Assign tasks for new passwords (#17268)
* Update type guard for cipher ids on reports * Update report model cipher id type and mock data * Update security tasks api service to have copied getAllTasks function from the vault team * Expose critical application at risk cipher ids * Update cipher id type in report service. Update all activities service to move task function to task service * Update module * Update organization id sharing through components instead of multiple route fetchings * Update view type of password change widget. Update variables to be signals. Refactor logic for calculations based on individual tasks * Update usage of request password change function * Update security tasks service to manage tasks * Remove unused variable * Alphabetized functions, added documentation. Removed injectable decorator * Alphabetize constructor params for password health service * Update providers * Address NaN case on percentage. Address obsolete type casting to CipherID and any other claude comments * Fix dependency array in test case
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { CipherId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
MemberDetails,
|
MemberDetails,
|
||||||
@@ -10,7 +12,6 @@ import {
|
|||||||
createValidator,
|
createValidator,
|
||||||
isBoolean,
|
isBoolean,
|
||||||
isBoundedString,
|
isBoundedString,
|
||||||
isBoundedStringArray,
|
|
||||||
isBoundedStringOrNull,
|
isBoundedStringOrNull,
|
||||||
isBoundedPositiveNumber,
|
isBoundedPositiveNumber,
|
||||||
BOUNDED_ARRAY_MAX_LENGTH,
|
BOUNDED_ARRAY_MAX_LENGTH,
|
||||||
@@ -33,6 +34,10 @@ export const isMemberDetails = createValidator<MemberDetails>({
|
|||||||
});
|
});
|
||||||
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
|
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
|
||||||
|
|
||||||
|
export function isCipherId(value: unknown): value is CipherId {
|
||||||
|
return value == null || isBoundedString(value);
|
||||||
|
}
|
||||||
|
export const isCipherIdArray = createBoundedArrayGuard(isCipherId);
|
||||||
/**
|
/**
|
||||||
* Type guard to validate ApplicationHealthReportDetail structure
|
* Type guard to validate ApplicationHealthReportDetail structure
|
||||||
* Exported for testability
|
* Exported for testability
|
||||||
@@ -40,11 +45,11 @@ export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
|
|||||||
*/
|
*/
|
||||||
export const isApplicationHealthReportDetail = createValidator<ApplicationHealthReportDetail>({
|
export const isApplicationHealthReportDetail = createValidator<ApplicationHealthReportDetail>({
|
||||||
applicationName: isBoundedString,
|
applicationName: isBoundedString,
|
||||||
atRiskCipherIds: isBoundedStringArray,
|
atRiskCipherIds: isCipherIdArray,
|
||||||
atRiskMemberCount: isBoundedPositiveNumber,
|
atRiskMemberCount: isBoundedPositiveNumber,
|
||||||
atRiskMemberDetails: isMemberDetailsArray,
|
atRiskMemberDetails: isMemberDetailsArray,
|
||||||
atRiskPasswordCount: isBoundedPositiveNumber,
|
atRiskPasswordCount: isBoundedPositiveNumber,
|
||||||
cipherIds: isBoundedStringArray,
|
cipherIds: isCipherIdArray,
|
||||||
memberCount: isBoundedPositiveNumber,
|
memberCount: isBoundedPositiveNumber,
|
||||||
memberDetails: isMemberDetailsArray,
|
memberDetails: isMemberDetailsArray,
|
||||||
passwordCount: isBoundedPositiveNumber,
|
passwordCount: isBoundedPositiveNumber,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { CipherId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
@@ -13,11 +14,14 @@ import {
|
|||||||
PasswordHealthData,
|
PasswordHealthData,
|
||||||
} from "../report-models";
|
} from "../report-models";
|
||||||
|
|
||||||
|
const mockCipherId1 = "cipher-1" as CipherId;
|
||||||
|
const mockCipherId2 = "cipher-2" as CipherId;
|
||||||
|
|
||||||
const mockApplication1: ApplicationHealthReportDetail = {
|
const mockApplication1: ApplicationHealthReportDetail = {
|
||||||
applicationName: "application1.com",
|
applicationName: "application1.com",
|
||||||
passwordCount: 2,
|
passwordCount: 2,
|
||||||
atRiskPasswordCount: 1,
|
atRiskPasswordCount: 1,
|
||||||
atRiskCipherIds: ["cipher-1"],
|
atRiskCipherIds: [mockCipherId1],
|
||||||
memberCount: 2,
|
memberCount: 2,
|
||||||
atRiskMemberCount: 1,
|
atRiskMemberCount: 1,
|
||||||
memberDetails: [
|
memberDetails: [
|
||||||
@@ -33,10 +37,10 @@ const mockApplication1: ApplicationHealthReportDetail = {
|
|||||||
userGuid: "user-id-2",
|
userGuid: "user-id-2",
|
||||||
userName: "tom",
|
userName: "tom",
|
||||||
email: "tom2@application1.com",
|
email: "tom2@application1.com",
|
||||||
cipherId: "cipher-2",
|
cipherId: mockCipherId2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cipherIds: ["cipher-1", "cipher-2"],
|
cipherIds: [mockCipherId1, mockCipherId2],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockApplication2: ApplicationHealthReportDetail = {
|
const mockApplication2: ApplicationHealthReportDetail = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Opaque } from "type-fest";
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { OrganizationReportId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { BadgeVariant } from "@bitwarden/components";
|
import { BadgeVariant } from "@bitwarden/components";
|
||||||
|
|
||||||
@@ -79,12 +79,12 @@ export type ApplicationHealthReportDetail = {
|
|||||||
applicationName: string;
|
applicationName: string;
|
||||||
passwordCount: number;
|
passwordCount: number;
|
||||||
atRiskPasswordCount: number;
|
atRiskPasswordCount: number;
|
||||||
atRiskCipherIds: string[];
|
atRiskCipherIds: CipherId[];
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
atRiskMemberCount: number;
|
atRiskMemberCount: number;
|
||||||
memberDetails: MemberDetails[];
|
memberDetails: MemberDetails[];
|
||||||
atRiskMemberDetails: MemberDetails[];
|
atRiskMemberDetails: MemberDetails[];
|
||||||
cipherIds: string[];
|
cipherIds: CipherId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------- Password Health Report Models --------------------
|
// -------------------- Password Health Report Models --------------------
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { from, Observable } from "rxjs";
|
import { from, Observable } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
import {
|
||||||
|
SecurityTask,
|
||||||
|
SecurityTaskData,
|
||||||
|
SecurityTaskResponse,
|
||||||
|
SecurityTaskStatus,
|
||||||
|
} from "@bitwarden/common/vault/tasks";
|
||||||
|
|
||||||
export type TaskMetrics = {
|
export type TaskMetrics = {
|
||||||
completedTasks: number;
|
completedTasks: number;
|
||||||
@@ -22,4 +29,29 @@ export class SecurityTasksApiService {
|
|||||||
|
|
||||||
return from(dbResponse as Promise<TaskMetrics>);
|
return from(dbResponse as Promise<TaskMetrics>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Could not import from @bitwarden/bit-web
|
||||||
|
// Copying from /bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts
|
||||||
|
async getAllTasks(
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
status?: SecurityTaskStatus | undefined,
|
||||||
|
): Promise<SecurityTask[]> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
queryParams.append("organizationId", organizationId);
|
||||||
|
if (status !== undefined) {
|
||||||
|
queryParams.append("status", status.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await this.apiService.send(
|
||||||
|
"GET",
|
||||||
|
`/tasks/organization?${queryParams.toString()}`,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const response = new ListResponse(r, SecurityTaskResponse);
|
||||||
|
|
||||||
|
return response.data.map((d) => new SecurityTask(new SecurityTaskData(d)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("PasswordHealthService", () => {
|
|||||||
auditService.passwordLeaked.mockImplementation((password: string) =>
|
auditService.passwordLeaked.mockImplementation((password: string) =>
|
||||||
Promise.resolve(password === "leaked" ? 2 : 0),
|
Promise.resolve(password === "leaked" ? 2 : 0),
|
||||||
);
|
);
|
||||||
service = new PasswordHealthService(passwordStrengthService, auditService);
|
service = new PasswordHealthService(auditService, passwordStrengthService);
|
||||||
|
|
||||||
// Setup mock data
|
// Setup mock data
|
||||||
mockValidCipher = mock<CipherView>({
|
mockValidCipher = mock<CipherView>({
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
|
|
||||||
export class PasswordHealthService {
|
export class PasswordHealthService {
|
||||||
constructor(
|
constructor(
|
||||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
@@ -89,6 +89,10 @@ export class RiskInsightsOrchestratorService {
|
|||||||
private _hasCiphersSubject$ = new BehaviorSubject<boolean | null>(null);
|
private _hasCiphersSubject$ = new BehaviorSubject<boolean | null>(null);
|
||||||
hasCiphers$ = this._hasCiphersSubject$.asObservable();
|
hasCiphers$ = this._hasCiphersSubject$.asObservable();
|
||||||
|
|
||||||
|
private _criticalApplicationAtRiskCipherIdsSubject$ = new BehaviorSubject<CipherId[]>([]);
|
||||||
|
readonly criticalApplicationAtRiskCipherIds$ =
|
||||||
|
this._criticalApplicationAtRiskCipherIdsSubject$.asObservable();
|
||||||
|
|
||||||
// ------------------------- Report Variables ----------------
|
// ------------------------- Report Variables ----------------
|
||||||
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
|
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
|
||||||
status: ReportStatus.Initializing,
|
status: ReportStatus.Initializing,
|
||||||
@@ -1150,10 +1154,42 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._reportStateSubscription = mergedReportState$
|
this._reportStateSubscription = mergedReportState$
|
||||||
.pipe(takeUntil(this._destroy$))
|
.pipe(takeUntil(this._destroy$))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
|
// Update the raw report data subject
|
||||||
this._rawReportDataSubject.next(state.reportState);
|
this._rawReportDataSubject.next(state.reportState);
|
||||||
|
|
||||||
|
// Update the critical application at risk cipher ids for exposure
|
||||||
|
const reportState = state.reportState?.data;
|
||||||
|
if (reportState) {
|
||||||
|
const criticalApplicationAtRiskCipherIds = this._getCriticalApplicationCipherIds(
|
||||||
|
reportState.reportData || [],
|
||||||
|
reportState.applicationData || [],
|
||||||
|
);
|
||||||
|
this._criticalApplicationAtRiskCipherIdsSubject$.next(criticalApplicationAtRiskCipherIds);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gets the unique cipher IDs that are marked at risk in critical applications
|
||||||
|
private _getCriticalApplicationCipherIds(
|
||||||
|
applications: ApplicationHealthReportDetail[],
|
||||||
|
applicationData: OrganizationReportApplication[],
|
||||||
|
): CipherId[] {
|
||||||
|
const foundCipherIds = applications
|
||||||
|
.map((app) => {
|
||||||
|
const isCriticalApplication = this.reportService.isCriticalApplication(
|
||||||
|
app,
|
||||||
|
applicationData,
|
||||||
|
);
|
||||||
|
return isCriticalApplication ? app.atRiskCipherIds : [];
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
// Use a set to ensure uniqueness
|
||||||
|
const uniqueCipherIds = new Set<CipherId>([...foundCipherIds]);
|
||||||
|
|
||||||
|
return [...uniqueCipherIds];
|
||||||
|
}
|
||||||
|
|
||||||
// Setup the user ID observable to track the current user
|
// Setup the user ID observable to track the current user
|
||||||
private _setupUserId() {
|
private _setupUserId() {
|
||||||
// Watch userId changes
|
// Watch userId changes
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs";
|
import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs";
|
||||||
|
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
import {
|
||||||
|
CipherId,
|
||||||
|
OrganizationId,
|
||||||
|
OrganizationReportId,
|
||||||
|
UserId,
|
||||||
|
} from "@bitwarden/common/types/guid";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
|
import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
|
||||||
@@ -63,7 +68,7 @@ export class RiskInsightsReportService {
|
|||||||
): Map<string, CipherView[]> {
|
): Map<string, CipherView[]> {
|
||||||
const cipherMap = new Map<string, CipherView[]>();
|
const cipherMap = new Map<string, CipherView[]>();
|
||||||
applications.forEach((app) => {
|
applications.forEach((app) => {
|
||||||
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
|
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id as CipherId));
|
||||||
cipherMap.set(app.applicationName, filteredCiphers);
|
cipherMap.set(app.applicationName, filteredCiphers);
|
||||||
});
|
});
|
||||||
return cipherMap;
|
return cipherMap;
|
||||||
@@ -346,7 +351,7 @@ export class RiskInsightsReportService {
|
|||||||
): ApplicationHealthReportDetail {
|
): ApplicationHealthReportDetail {
|
||||||
return {
|
return {
|
||||||
applicationName: application,
|
applicationName: application,
|
||||||
cipherIds: [cipherReport.cipher.id],
|
cipherIds: [cipherReport.cipher.id as CipherId],
|
||||||
passwordCount: 1,
|
passwordCount: 1,
|
||||||
memberDetails: [...cipherReport.cipherMembers],
|
memberDetails: [...cipherReport.cipherMembers],
|
||||||
memberCount: cipherReport.cipherMembers.length,
|
memberCount: cipherReport.cipherMembers.length,
|
||||||
@@ -367,7 +372,7 @@ export class RiskInsightsReportService {
|
|||||||
memberDetails: getUniqueMembers(
|
memberDetails: getUniqueMembers(
|
||||||
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
|
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
|
||||||
),
|
),
|
||||||
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
|
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id as CipherId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +382,7 @@ export class RiskInsightsReportService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
atRiskPasswordCount: report.atRiskPasswordCount + 1,
|
atRiskPasswordCount: report.atRiskPasswordCount + 1,
|
||||||
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
|
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id as CipherId),
|
||||||
atRiskMemberDetails,
|
atRiskMemberDetails,
|
||||||
atRiskMemberCount: atRiskMemberDetails.length,
|
atRiskMemberCount: atRiskMemberDetails.length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export class AllActivitiesService {
|
|||||||
/// and critical applications.
|
/// and critical applications.
|
||||||
/// Going forward, this class can be simplified by using the RiskInsightsDataService
|
/// Going forward, this class can be simplified by using the RiskInsightsDataService
|
||||||
/// as it contains the application summary data.
|
/// as it contains the application summary data.
|
||||||
|
|
||||||
private reportSummarySubject$ = new BehaviorSubject<OrganizationReportSummary>({
|
private reportSummarySubject$ = new BehaviorSubject<OrganizationReportSummary>({
|
||||||
totalMemberCount: 0,
|
totalMemberCount: 0,
|
||||||
totalCriticalMemberCount: 0,
|
totalCriticalMemberCount: 0,
|
||||||
@@ -31,12 +30,8 @@ export class AllActivitiesService {
|
|||||||
private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0);
|
private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0);
|
||||||
atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable();
|
atRiskPasswordsCount$ = this.atRiskPasswordsCountSubject$.asObservable();
|
||||||
|
|
||||||
private passwordChangeProgressMetricHasProgressBarSubject$ = new BehaviorSubject<boolean>(false);
|
private extendPasswordChangeWidgetSubject$ = new BehaviorSubject<boolean>(false);
|
||||||
passwordChangeProgressMetricHasProgressBar$ =
|
extendPasswordChangeWidget$ = this.extendPasswordChangeWidgetSubject$.asObservable();
|
||||||
this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable();
|
|
||||||
|
|
||||||
private taskCreatedCountSubject$ = new BehaviorSubject<number>(0);
|
|
||||||
taskCreatedCount$ = this.taskCreatedCountSubject$.asObservable();
|
|
||||||
|
|
||||||
constructor(private dataService: RiskInsightsDataService) {
|
constructor(private dataService: RiskInsightsDataService) {
|
||||||
// All application summary changes
|
// All application summary changes
|
||||||
@@ -91,11 +86,7 @@ export class AllActivitiesService {
|
|||||||
this.allApplicationsDetailsSubject$.next(applications);
|
this.allApplicationsDetailsSubject$.next(applications);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) {
|
setExtendPasswordWidget(hasProgressBar: boolean) {
|
||||||
this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar);
|
this.extendPasswordChangeWidgetSubject$.next(hasProgressBar);
|
||||||
}
|
|
||||||
|
|
||||||
setTaskCreatedCount(count: number) {
|
|
||||||
this.taskCreatedCountSubject$.next(count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
|
||||||
import { distinctUntilChanged, map } from "rxjs/operators";
|
import { distinctUntilChanged, map } from "rxjs/operators";
|
||||||
|
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,7 @@ export class RiskInsightsDataService {
|
|||||||
readonly isGeneratingReport$: Observable<boolean> = of(false);
|
readonly isGeneratingReport$: Observable<boolean> = of(false);
|
||||||
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||||
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
||||||
|
readonly criticalApplicationAtRiskCipherIds$: Observable<CipherId[]> = of([]);
|
||||||
readonly reportProgress$: Observable<ReportProgress | null> = of(null);
|
readonly reportProgress$: Observable<ReportProgress | null> = of(null);
|
||||||
|
|
||||||
// New applications that need review (reviewedDate === null)
|
// New applications that need review (reviewedDate === null)
|
||||||
@@ -64,6 +65,8 @@ export class RiskInsightsDataService {
|
|||||||
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
|
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
|
||||||
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
|
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
|
||||||
this.newApplications$ = this.orchestrator.newApplications$;
|
this.newApplications$ = this.orchestrator.newApplications$;
|
||||||
|
this.criticalApplicationAtRiskCipherIds$ =
|
||||||
|
this.orchestrator.criticalApplicationAtRiskCipherIds$;
|
||||||
this.reportProgress$ = this.orchestrator.reportProgress$;
|
this.reportProgress$ = this.orchestrator.reportProgress$;
|
||||||
|
|
||||||
this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged());
|
this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged());
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
|||||||
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { ToastService } from "@bitwarden/components";
|
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
@@ -37,22 +35,37 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
|
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
|
||||||
providers: [
|
providers: [
|
||||||
safeProvider(DefaultAdminTaskService),
|
safeProvider({
|
||||||
|
provide: CriticalAppsApiService,
|
||||||
|
useClass: CriticalAppsApiService,
|
||||||
|
deps: [ApiService],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: MemberCipherDetailsApiService,
|
provide: MemberCipherDetailsApiService,
|
||||||
useClass: MemberCipherDetailsApiService,
|
useClass: MemberCipherDetailsApiService,
|
||||||
deps: [ApiService],
|
deps: [ApiService],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
|
||||||
provide: PasswordHealthService,
|
|
||||||
useClass: PasswordHealthService,
|
|
||||||
deps: [PasswordStrengthServiceAbstraction, AuditService],
|
|
||||||
}),
|
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: RiskInsightsApiService,
|
provide: RiskInsightsApiService,
|
||||||
useClass: RiskInsightsApiService,
|
useClass: RiskInsightsApiService,
|
||||||
deps: [ApiService],
|
deps: [ApiService],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: SecurityTasksApiService,
|
||||||
|
useClass: SecurityTasksApiService,
|
||||||
|
deps: [ApiService],
|
||||||
|
}),
|
||||||
|
safeProvider(DefaultAdminTaskService),
|
||||||
|
safeProvider({
|
||||||
|
provide: AccessIntelligenceSecurityTasksService,
|
||||||
|
useClass: AccessIntelligenceSecurityTasksService,
|
||||||
|
deps: [DefaultAdminTaskService, SecurityTasksApiService],
|
||||||
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: PasswordHealthService,
|
||||||
|
useClass: PasswordHealthService,
|
||||||
|
deps: [AuditService, PasswordStrengthServiceAbstraction],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: RiskInsightsReportService,
|
provide: RiskInsightsReportService,
|
||||||
useClass: RiskInsightsReportService,
|
useClass: RiskInsightsReportService,
|
||||||
@@ -86,26 +99,11 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
|
|||||||
useClass: CriticalAppsService,
|
useClass: CriticalAppsService,
|
||||||
deps: [KeyService, EncryptService, CriticalAppsApiService],
|
deps: [KeyService, EncryptService, CriticalAppsApiService],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
|
||||||
provide: CriticalAppsApiService,
|
|
||||||
useClass: CriticalAppsApiService,
|
|
||||||
deps: [ApiService],
|
|
||||||
}),
|
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: AllActivitiesService,
|
provide: AllActivitiesService,
|
||||||
useClass: AllActivitiesService,
|
useClass: AllActivitiesService,
|
||||||
deps: [RiskInsightsDataService],
|
deps: [RiskInsightsDataService],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
|
||||||
provide: SecurityTasksApiService,
|
|
||||||
useClass: SecurityTasksApiService,
|
|
||||||
deps: [ApiService],
|
|
||||||
}),
|
|
||||||
safeProvider({
|
|
||||||
provide: AccessIntelligenceSecurityTasksService,
|
|
||||||
useClass: AccessIntelligenceSecurityTasksService,
|
|
||||||
deps: [AllActivitiesService, DefaultAdminTaskService, ToastService, I18nService],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AccessIntelligenceModule {}
|
export class AccessIntelligenceModule {}
|
||||||
|
|||||||
@@ -5,75 +5,80 @@
|
|||||||
{{ "passwordChangeProgress" | i18n }}
|
{{ "passwordChangeProgress" | i18n }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (renderMode === renderModes.noCriticalApps) {
|
@switch (currentView()) {
|
||||||
<div class="tw-items-start tw-mb-2">
|
@case (PasswordChangeViewEnum.EMPTY) {
|
||||||
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
|
<div class="tw-items-start tw-mb-2">
|
||||||
</div>
|
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
|
||||||
|
|
||||||
<div class="tw-items-baseline tw-gap-2">
|
|
||||||
<span bitTypography="body2">{{ "onceYouReviewApps" | i18n }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndNoTasks) {
|
|
||||||
<div class="tw-items-start tw-mb-2">
|
|
||||||
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tw-items-baseline tw-gap-2">
|
|
||||||
<span bitTypography="body2">{{
|
|
||||||
hasExistingTasks
|
|
||||||
? ("newPasswordsAtRisk" | i18n: newAtRiskPasswordsCount)
|
|
||||||
: ("countOfAtRiskPasswords" | i18n: atRiskPasswordsCount)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tw-mt-4">
|
|
||||||
<button
|
|
||||||
bitButton
|
|
||||||
buttonType="secondary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!canAssignTasks"
|
|
||||||
(click)="assignTasks()"
|
|
||||||
>
|
|
||||||
<i class="bwi bwi-envelope tw-mr-2"></i>
|
|
||||||
{{ "assignTasks" | i18n }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndTasks) {
|
|
||||||
<div class="tw-items-start tw-mb-2">
|
|
||||||
<span bitTypography="h3">{{ "percentageCompleted" | i18n: completedPercent }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tw-items-baseline tw-gap-2">
|
|
||||||
<span bitTypography="body2">{{
|
|
||||||
"securityTasksCompleted" | i18n: completedTasksCount : totalTasksCount
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tw-mt-4">
|
|
||||||
<div class="tw-flex tw-justify-between">
|
|
||||||
<div bitTypography="body2">{{ completedTasksCount }}</div>
|
|
||||||
<div bitTypography="body2">{{ totalTasksCount }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<bit-progress
|
|
||||||
[showText]="false"
|
|
||||||
size="small"
|
|
||||||
bgColor="primary"
|
|
||||||
[barWidth]="completedPercent"
|
|
||||||
[ariaLabel]="'passwordChangeProgressBar' | i18n"
|
|
||||||
>
|
|
||||||
</bit-progress>
|
|
||||||
|
|
||||||
<!-- TODO: Implement reminder functionality -->
|
<div class="tw-items-baseline tw-gap-2">
|
||||||
<!-- <div class="tw-items-start tw-mt-4 tw-gap-4">
|
<span bitTypography="body2">{{ "onceYouReviewApps" | i18n }}</span>
|
||||||
<button bitButton type="button" buttonType="secondary">
|
</div>
|
||||||
<i class="bwi bwi-envelope" aria-hidden="true"></i>
|
}
|
||||||
{{ "sendReminders" | i18n }}
|
|
||||||
</button>
|
@case (PasswordChangeViewEnum.NO_TASKS_ASSIGNED) {
|
||||||
</div> -->
|
<div class="tw-items-start tw-mb-2">
|
||||||
|
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-items-baseline tw-gap-2">
|
||||||
|
<span bitTypography="body2">{{
|
||||||
|
"countOfAtRiskPasswords" | i18n: atRiskPasswordCount()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (atRiskPasswordCount() > 0) {
|
||||||
|
<div class="tw-mt-4">
|
||||||
|
<button bitButton buttonType="secondary" type="button" (click)="assignTasks()">
|
||||||
|
<i class="bwi bwi-envelope tw-mr-2"></i>
|
||||||
|
{{ "assignTasks" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@case (PasswordChangeViewEnum.NEW_TASKS_AVAILABLE) {
|
||||||
|
<div class="tw-items-start tw-mb-2">
|
||||||
|
<span bitTypography="h3">{{ "assignMembersTasksToMonitorProgress" | i18n }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-items-baseline tw-gap-2">
|
||||||
|
<span bitTypography="body2">{{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-mt-4">
|
||||||
|
<button bitButton buttonType="secondary" type="button" (click)="assignTasks()">
|
||||||
|
<i class="bwi bwi-envelope tw-mr-2"></i>
|
||||||
|
{{ "assignTasks" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case (PasswordChangeViewEnum.PROGRESS) {
|
||||||
|
<div class="tw-items-start tw-mb-2">
|
||||||
|
<span bitTypography="h3">{{ "percentageCompleted" | i18n: completedTasksPercent() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-items-baseline tw-gap-2">
|
||||||
|
<span bitTypography="body2">{{
|
||||||
|
"securityTasksCompleted" | i18n: completedTasksCount() : tasksCount()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-mt-4">
|
||||||
|
<div class="tw-flex tw-justify-between">
|
||||||
|
<div bitTypography="body2">{{ completedTasksCount() }}</div>
|
||||||
|
<div bitTypography="body2">{{ tasksCount() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<bit-progress
|
||||||
|
[showText]="false"
|
||||||
|
size="small"
|
||||||
|
bgColor="primary"
|
||||||
|
[barWidth]="completedTasksPercent()"
|
||||||
|
[ariaLabel]="'passwordChangeProgressBar' | i18n"
|
||||||
|
>
|
||||||
|
</bit-progress>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,197 +1,169 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
|
Injector,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
Signal,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { map } from "rxjs";
|
||||||
import { switchMap, of, BehaviorSubject, combineLatest } from "rxjs";
|
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
ApplicationHealthReportDetailEnriched,
|
RiskInsightsDataService,
|
||||||
SecurityTasksApiService,
|
|
||||||
TaskMetrics,
|
|
||||||
OrganizationReportSummary,
|
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
|
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks";
|
||||||
|
import {
|
||||||
|
ButtonModule,
|
||||||
|
ProgressModule,
|
||||||
|
ToastService,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
|
|
||||||
import { RenderMode } from "../../models/activity.models";
|
|
||||||
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
||||||
|
|
||||||
|
export const PasswordChangeView = {
|
||||||
|
EMPTY: "empty",
|
||||||
|
NO_TASKS_ASSIGNED: "noTasksAssigned",
|
||||||
|
NEW_TASKS_AVAILABLE: "newTasks",
|
||||||
|
PROGRESS: "progress",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof PasswordChangeView];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: "dirt-password-change-metric",
|
selector: "dirt-password-change-metric",
|
||||||
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
||||||
templateUrl: "./password-change-metric.component.html",
|
templateUrl: "./password-change-metric.component.html",
|
||||||
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
|
|
||||||
})
|
})
|
||||||
export class PasswordChangeMetricComponent implements OnInit {
|
export class PasswordChangeMetricComponent implements OnInit {
|
||||||
|
PasswordChangeViewEnum = PasswordChangeView;
|
||||||
|
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
|
// Inputs
|
||||||
private completedTasks: number = 0;
|
// Prefer component input since route param controls UI state
|
||||||
private totalTasks: number = 0;
|
readonly organizationId = input.required<OrganizationId>();
|
||||||
private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = [];
|
|
||||||
|
|
||||||
atRiskAppsCount: number = 0;
|
// Signal states
|
||||||
atRiskPasswordsCount: number = 0;
|
private readonly _tasks: Signal<SecurityTask[]> = signal<SecurityTask[]>([]);
|
||||||
private organizationId!: OrganizationId;
|
private readonly _atRiskCipherIds: Signal<CipherId[]> = signal<CipherId[]>([]);
|
||||||
renderMode: RenderMode = "noCriticalApps";
|
private readonly _hasCriticalApplications: Signal<boolean> = signal<boolean>(false);
|
||||||
|
|
||||||
// Computed properties (formerly getters) - updated when data changes
|
// Computed properties
|
||||||
protected completedPercent = 0;
|
readonly tasksCount = computed(() => this._tasks().length);
|
||||||
protected completedTasksCount = 0;
|
readonly completedTasksCount = computed(
|
||||||
protected totalTasksCount = 0;
|
() => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length,
|
||||||
protected canAssignTasks = false;
|
);
|
||||||
protected hasExistingTasks = false;
|
readonly uncompletedTasksCount = computed(
|
||||||
protected newAtRiskPasswordsCount = 0;
|
() => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length,
|
||||||
|
);
|
||||||
|
readonly completedTasksPercent = computed(() => {
|
||||||
|
const total = this.tasksCount();
|
||||||
|
// Account for case where there are no tasks to avoid NaN
|
||||||
|
return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly atRiskPasswordCount = computed<number>(() => {
|
||||||
|
const atRiskIds = this._atRiskCipherIds();
|
||||||
|
const tasks = this._tasks();
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return atRiskIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedIdSet = new Set(tasks.map((task) => task.cipherId));
|
||||||
|
const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id));
|
||||||
|
|
||||||
|
return unassignedIds.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly currentView = computed<PasswordChangeView>(() => {
|
||||||
|
if (!this._hasCriticalApplications()) {
|
||||||
|
return PasswordChangeView.EMPTY;
|
||||||
|
}
|
||||||
|
if (this.tasksCount() === 0) {
|
||||||
|
return PasswordChangeView.NO_TASKS_ASSIGNED;
|
||||||
|
}
|
||||||
|
if (this.atRiskPasswordCount() > 0) {
|
||||||
|
return PasswordChangeView.NEW_TASKS_AVAILABLE;
|
||||||
|
}
|
||||||
|
return PasswordChangeView.PROGRESS;
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private activatedRoute: ActivatedRoute,
|
|
||||||
private securityTasksApiService: SecurityTasksApiService,
|
|
||||||
private allActivitiesService: AllActivitiesService,
|
private allActivitiesService: AllActivitiesService,
|
||||||
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
private i18nService: I18nService,
|
||||||
private cdr: ChangeDetectorRef,
|
private injector: Injector,
|
||||||
) {}
|
private riskInsightsDataService: RiskInsightsDataService,
|
||||||
|
protected securityTasksService: AccessIntelligenceSecurityTasksService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
) {
|
||||||
|
// Setup the _tasks signal by manually passing in the injector
|
||||||
|
this._tasks = toSignal(this.securityTasksService.tasks$, {
|
||||||
|
initialValue: [],
|
||||||
|
injector: this.injector,
|
||||||
|
});
|
||||||
|
// Setup the _atRiskCipherIds signal by manually passing in the injector
|
||||||
|
this._atRiskCipherIds = toSignal(
|
||||||
|
this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$,
|
||||||
|
{
|
||||||
|
initialValue: [],
|
||||||
|
injector: this.injector,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this._hasCriticalApplications = toSignal(
|
||||||
|
this.riskInsightsDataService.criticalReportResults$.pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
map((report) => {
|
||||||
|
return report != null && (report.reportData?.length ?? 0) > 0;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
initialValue: false,
|
||||||
|
injector: this.injector,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS;
|
||||||
|
this.allActivitiesService.setExtendPasswordWidget(isShowingProgress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
|
await this.securityTasksService.loadTasks(this.organizationId());
|
||||||
.pipe(
|
|
||||||
switchMap(([params, _]) => {
|
|
||||||
const orgId = params.get("organizationId");
|
|
||||||
if (orgId) {
|
|
||||||
this.organizationId = orgId as OrganizationId;
|
|
||||||
return this.securityTasksApiService.getTaskMetrics(this.organizationId);
|
|
||||||
}
|
|
||||||
return of({ totalTasks: 0, completedTasks: 0 });
|
|
||||||
}),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe((metrics) => {
|
|
||||||
this.taskMetrics$.next(metrics);
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
combineLatest([
|
|
||||||
this.taskMetrics$,
|
|
||||||
this.allActivitiesService.reportSummary$,
|
|
||||||
this.allActivitiesService.atRiskPasswordsCount$,
|
|
||||||
this.allActivitiesService.allApplicationsDetails$,
|
|
||||||
])
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe(([taskMetrics, summary, atRiskPasswordsCount, allApplicationsDetails]) => {
|
|
||||||
this.atRiskAppsCount = summary.totalCriticalAtRiskApplicationCount;
|
|
||||||
this.atRiskPasswordsCount = atRiskPasswordsCount;
|
|
||||||
this.completedTasks = taskMetrics.completedTasks;
|
|
||||||
this.totalTasks = taskMetrics.totalTasks;
|
|
||||||
this.allApplicationsDetails = allApplicationsDetails;
|
|
||||||
|
|
||||||
// Determine render mode based on state
|
|
||||||
this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount);
|
|
||||||
|
|
||||||
this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar(
|
|
||||||
this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update all computed properties when data changes
|
|
||||||
this.updateComputedProperties();
|
|
||||||
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private determineRenderMode(
|
|
||||||
summary: OrganizationReportSummary,
|
|
||||||
taskMetrics: TaskMetrics,
|
|
||||||
atRiskPasswordsCount: number,
|
|
||||||
): RenderMode {
|
|
||||||
// State 1: No critical apps setup
|
|
||||||
if (summary.totalCriticalApplicationCount === 0) {
|
|
||||||
return RenderMode.noCriticalApps;
|
|
||||||
}
|
|
||||||
|
|
||||||
// State 2: Critical apps with at-risk passwords but no tasks assigned yet
|
|
||||||
// OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks)
|
|
||||||
if (
|
|
||||||
summary.totalCriticalApplicationCount > 0 &&
|
|
||||||
(taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks)
|
|
||||||
) {
|
|
||||||
return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// State 3: Critical apps with at-risk apps and tasks (progress tracking)
|
|
||||||
if (
|
|
||||||
summary.totalCriticalApplicationCount > 0 &&
|
|
||||||
taskMetrics.totalTasks > 0 &&
|
|
||||||
atRiskPasswordsCount <= taskMetrics.totalTasks
|
|
||||||
) {
|
|
||||||
return RenderMode.criticalAppsWithAtRiskAppsAndTasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to no critical apps
|
|
||||||
return RenderMode.noCriticalApps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates all computed properties based on current state.
|
|
||||||
* Called whenever data changes to avoid recalculation on every change detection cycle.
|
|
||||||
*/
|
|
||||||
private updateComputedProperties(): void {
|
|
||||||
// Calculate completion percentage
|
|
||||||
this.completedPercent =
|
|
||||||
this.totalTasks === 0 ? 0 : Math.round((this.completedTasks / this.totalTasks) * 100);
|
|
||||||
|
|
||||||
// Calculate completed tasks count based on render mode
|
|
||||||
switch (this.renderMode) {
|
|
||||||
case RenderMode.noCriticalApps:
|
|
||||||
case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks:
|
|
||||||
this.completedTasksCount = 0;
|
|
||||||
break;
|
|
||||||
case RenderMode.criticalAppsWithAtRiskAppsAndTasks:
|
|
||||||
this.completedTasksCount = this.completedTasks;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.completedTasksCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total tasks count based on render mode
|
|
||||||
switch (this.renderMode) {
|
|
||||||
case RenderMode.noCriticalApps:
|
|
||||||
this.totalTasksCount = 0;
|
|
||||||
break;
|
|
||||||
case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks:
|
|
||||||
this.totalTasksCount = this.atRiskAppsCount;
|
|
||||||
break;
|
|
||||||
case RenderMode.criticalAppsWithAtRiskAppsAndTasks:
|
|
||||||
this.totalTasksCount = this.totalTasks;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.totalTasksCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate flags and counts
|
|
||||||
this.canAssignTasks = this.atRiskPasswordsCount > this.totalTasks;
|
|
||||||
this.hasExistingTasks = this.totalTasks > 0;
|
|
||||||
this.newAtRiskPasswordsCount =
|
|
||||||
this.atRiskPasswordsCount > this.totalTasks ? this.atRiskPasswordsCount - this.totalTasks : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get renderModes() {
|
|
||||||
return RenderMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignTasks() {
|
async assignTasks() {
|
||||||
await this.accessIntelligenceSecurityTasksService.assignTasks(
|
try {
|
||||||
this.organizationId,
|
await this.securityTasksService.requestPasswordChangeForCriticalApplications(
|
||||||
this.allApplicationsDetails.filter((app) => app.isMarkedAsCritical),
|
this.organizationId(),
|
||||||
);
|
this._atRiskCipherIds(),
|
||||||
|
);
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: this.i18nService.t("notifiedMembers"),
|
||||||
|
variant: "success",
|
||||||
|
title: this.i18nService.t("success"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: this.i18nService.t("unexpectedError"),
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
<ul
|
<ul
|
||||||
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
|
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
|
||||||
>
|
>
|
||||||
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
|
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': extendPasswordChangeWidget }">
|
||||||
<dirt-password-change-metric></dirt-password-change-metric>
|
<dirt-password-change-metric
|
||||||
|
[organizationId]="this.organizationId()"
|
||||||
|
></dirt-password-change-metric>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="tw-col-span-1">
|
<li class="tw-col-span-1">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
import { Component, DestroyRef, inject, input, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
import { lastValueFrom } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
@@ -10,10 +10,6 @@ import {
|
|||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
|
||||||
import { getById } from "@bitwarden/common/platform/misc";
|
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
@@ -37,13 +33,15 @@ import { NewApplicationsDialogComponent } from "./application-review-dialog/new-
|
|||||||
templateUrl: "./all-activity.component.html",
|
templateUrl: "./all-activity.component.html",
|
||||||
})
|
})
|
||||||
export class AllActivityComponent implements OnInit {
|
export class AllActivityComponent implements OnInit {
|
||||||
organization: Organization | null = null;
|
// Prefer component input since route param controls UI state
|
||||||
|
readonly organizationId = input.required<OrganizationId>();
|
||||||
|
|
||||||
totalCriticalAppsAtRiskMemberCount = 0;
|
totalCriticalAppsAtRiskMemberCount = 0;
|
||||||
totalCriticalAppsCount = 0;
|
totalCriticalAppsCount = 0;
|
||||||
totalCriticalAppsAtRiskCount = 0;
|
totalCriticalAppsAtRiskCount = 0;
|
||||||
newApplicationsCount = 0;
|
newApplicationsCount = 0;
|
||||||
newApplications: ApplicationHealthReportDetail[] = [];
|
newApplications: ApplicationHealthReportDetail[] = [];
|
||||||
passwordChangeMetricHasProgressBar = false;
|
extendPasswordChangeWidget = false;
|
||||||
allAppsHaveReviewDate = false;
|
allAppsHaveReviewDate = false;
|
||||||
isAllCaughtUp = false;
|
isAllCaughtUp = false;
|
||||||
hasLoadedApplicationData = false;
|
hasLoadedApplicationData = false;
|
||||||
@@ -53,7 +51,6 @@ export class AllActivityComponent implements OnInit {
|
|||||||
protected ReportStatusEnum = ReportStatus;
|
protected ReportStatusEnum = ReportStatus;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private accountService: AccountService,
|
|
||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
protected allActivitiesService: AllActivitiesService,
|
protected allActivitiesService: AllActivitiesService,
|
||||||
protected dataService: RiskInsightsDataService,
|
protected dataService: RiskInsightsDataService,
|
||||||
@@ -62,53 +59,43 @@ export class AllActivityComponent implements OnInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
this.allActivitiesService.reportSummary$
|
||||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe((summary) => {
|
||||||
|
this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount;
|
||||||
|
this.totalCriticalAppsCount = summary.totalCriticalApplicationCount;
|
||||||
|
this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount;
|
||||||
|
});
|
||||||
|
|
||||||
if (organizationId) {
|
this.dataService.newApplications$
|
||||||
this.organization =
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
(await firstValueFrom(
|
.subscribe((newApps) => {
|
||||||
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
|
this.newApplications = newApps;
|
||||||
)) ?? null;
|
this.newApplicationsCount = newApps.length;
|
||||||
|
this.updateIsAllCaughtUp();
|
||||||
|
});
|
||||||
|
|
||||||
this.allActivitiesService.reportSummary$
|
this.allActivitiesService.extendPasswordChangeWidget$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((summary) => {
|
.subscribe((hasProgressBar) => {
|
||||||
this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount;
|
this.extendPasswordChangeWidget = hasProgressBar;
|
||||||
this.totalCriticalAppsCount = summary.totalCriticalApplicationCount;
|
});
|
||||||
this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dataService.newApplications$
|
this.dataService.enrichedReportData$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((newApps) => {
|
.subscribe((enrichedData) => {
|
||||||
this.newApplications = newApps;
|
if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) {
|
||||||
this.newApplicationsCount = newApps.length;
|
this.hasLoadedApplicationData = true;
|
||||||
this.updateIsAllCaughtUp();
|
// Check if all apps have a review date (not null and not undefined)
|
||||||
});
|
this.allAppsHaveReviewDate = enrichedData.applicationData.every(
|
||||||
|
(app) => app.reviewedDate !== null && app.reviewedDate !== undefined,
|
||||||
this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$
|
);
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
} else {
|
||||||
.subscribe((hasProgressBar) => {
|
this.hasLoadedApplicationData = enrichedData !== null;
|
||||||
this.passwordChangeMetricHasProgressBar = hasProgressBar;
|
this.allAppsHaveReviewDate = false;
|
||||||
});
|
}
|
||||||
|
this.updateIsAllCaughtUp();
|
||||||
this.dataService.enrichedReportData$
|
});
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe((enrichedData) => {
|
|
||||||
if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) {
|
|
||||||
this.hasLoadedApplicationData = true;
|
|
||||||
// Check if all apps have a review date (not null and not undefined)
|
|
||||||
this.allAppsHaveReviewDate = enrichedData.applicationData.every(
|
|
||||||
(app) => app.reviewedDate !== null && app.reviewedDate !== undefined,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.hasLoadedApplicationData = enrichedData !== null;
|
|
||||||
this.allAppsHaveReviewDate = false;
|
|
||||||
}
|
|
||||||
this.updateIsAllCaughtUp();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import {
|
|||||||
import { I18nPipe } from "@bitwarden/ui-common";
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
import { DarkImageSourceDirective } from "@bitwarden/vault";
|
import { DarkImageSourceDirective } from "@bitwarden/vault";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
|
|
||||||
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embedded component for displaying task assignment UI.
|
* Embedded component for displaying task assignment UI.
|
||||||
* Not a dialog - intended to be embedded within a parent dialog.
|
* Not a dialog - intended to be embedded within a parent dialog.
|
||||||
@@ -36,7 +33,6 @@ import { AccessIntelligenceSecurityTasksService } from "../../shared/security-ta
|
|||||||
DarkImageSourceDirective,
|
DarkImageSourceDirective,
|
||||||
CalloutComponent,
|
CalloutComponent,
|
||||||
],
|
],
|
||||||
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
|
|
||||||
})
|
})
|
||||||
export class AssignTasksViewComponent {
|
export class AssignTasksViewComponent {
|
||||||
readonly criticalApplicationsCount = input.required<number>();
|
readonly criticalApplicationsCount = input.required<number>();
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { from, switchMap } from "rxjs";
|
import { from, switchMap, take } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
ApplicationHealthReportDetailEnriched,
|
|
||||||
OrganizationReportApplication,
|
|
||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||||
@@ -209,40 +207,16 @@ export class NewApplicationsDialogComponent {
|
|||||||
}
|
}
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
|
|
||||||
// Create updated organization report application types with new review date
|
|
||||||
// and critical marking based on selected applications
|
|
||||||
const newReviewDate = new Date();
|
|
||||||
const updatedApplications: OrganizationReportApplication[] =
|
|
||||||
this.dialogParams.newApplications.map((app) => ({
|
|
||||||
applicationName: app.applicationName,
|
|
||||||
isCritical: this.selectedApplications().has(app.applicationName),
|
|
||||||
reviewedDate: newReviewDate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Save the application review dates and critical markings
|
// Save the application review dates and critical markings
|
||||||
this.dataService
|
this.dataService.criticalApplicationAtRiskCipherIds$
|
||||||
.saveApplicationReviewStatus(updatedApplications)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
|
||||||
switchMap((updatedState) => {
|
take(1), // Handle unsubscribe for one off operation
|
||||||
// After initial save is complete, created the assigned tasks
|
switchMap((criticalApplicationAtRiskCipherIds) => {
|
||||||
// for at risk passwords
|
|
||||||
const updatedStateApplicationData = updatedState?.data?.applicationData || [];
|
|
||||||
// Manual enrich for type matching
|
|
||||||
// TODO Consolidate in model updates
|
|
||||||
const manualEnrichedApplications =
|
|
||||||
updatedState?.data?.reportData.map(
|
|
||||||
(application): ApplicationHealthReportDetailEnriched => ({
|
|
||||||
...application,
|
|
||||||
isMarkedAsCritical: updatedStateApplicationData.some(
|
|
||||||
(a) => a.applicationName == application.applicationName && a.isCritical,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
) || [];
|
|
||||||
return from(
|
return from(
|
||||||
this.accessIntelligenceSecurityTasksService.assignTasks(
|
this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications(
|
||||||
this.dialogParams.organizationId,
|
this.dialogParams.organizationId,
|
||||||
manualEnrichedApplications,
|
criticalApplicationAtRiskCipherIds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
|||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormControl } from "@angular/forms";
|
import { FormControl } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { debounceTime, EMPTY, map, switchMap } from "rxjs";
|
import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs";
|
||||||
|
|
||||||
import { Security } from "@bitwarden/assets/svg";
|
import { Security } from "@bitwarden/assets/svg";
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,6 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
|
|||||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
|
||||||
import { RiskInsightsTabType } from "../models/risk-insights.models";
|
import { RiskInsightsTabType } from "../models/risk-insights.models";
|
||||||
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
||||||
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
||||||
@@ -42,7 +41,6 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks
|
|||||||
SharedModule,
|
SharedModule,
|
||||||
AppTableRowScrollableComponent,
|
AppTableRowScrollableComponent,
|
||||||
],
|
],
|
||||||
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
|
|
||||||
})
|
})
|
||||||
export class CriticalApplicationsComponent implements OnInit {
|
export class CriticalApplicationsComponent implements OnInit {
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
@@ -58,13 +56,13 @@ export class CriticalApplicationsComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
protected router: Router,
|
|
||||||
protected toastService: ToastService,
|
|
||||||
protected dataService: RiskInsightsDataService,
|
protected dataService: RiskInsightsDataService,
|
||||||
protected criticalAppsService: CriticalAppsService,
|
protected criticalAppsService: CriticalAppsService,
|
||||||
protected reportService: RiskInsightsReportService,
|
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
protected reportService: RiskInsightsReportService,
|
||||||
|
protected router: Router,
|
||||||
|
private securityTasksService: AccessIntelligenceSecurityTasksService,
|
||||||
|
protected toastService: ToastService,
|
||||||
) {
|
) {
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||||
@@ -131,10 +129,35 @@ export class CriticalApplicationsComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async requestPasswordChange() {
|
async requestPasswordChange() {
|
||||||
await this.accessIntelligenceSecurityTasksService.assignTasks(
|
this.dataService.criticalApplicationAtRiskCipherIds$
|
||||||
this.organizationId,
|
.pipe(
|
||||||
this.dataSource.data,
|
takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
|
||||||
);
|
take(1), // Handle unsubscribe for one off operation
|
||||||
|
switchMap((cipherIds) => {
|
||||||
|
return from(
|
||||||
|
this.securityTasksService.requestPasswordChangeForCriticalApplications(
|
||||||
|
this.organizationId,
|
||||||
|
cipherIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: this.i18nService.t("notifiedMembers"),
|
||||||
|
variant: "success",
|
||||||
|
title: this.i18nService.t("success"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: this.i18nService.t("unexpectedError"),
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("error"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showAppAtRiskMembers = async (applicationName: string) => {
|
showAppAtRiskMembers = async (applicationName: string) => {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export const RenderMode = {
|
|
||||||
noCriticalApps: "noCriticalApps",
|
|
||||||
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
|
|
||||||
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
|
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
||||||
@if (isRiskInsightsActivityTabFeatureEnabled) {
|
@if (isRiskInsightsActivityTabFeatureEnabled) {
|
||||||
<bit-tab label="{{ 'activity' | i18n }}">
|
<bit-tab label="{{ 'activity' | i18n }}">
|
||||||
<dirt-all-activity></dirt-all-activity>
|
<dirt-all-activity [organizationId]="this.organizationId"></dirt-all-activity>
|
||||||
</bit-tab>
|
</bit-tab>
|
||||||
}
|
}
|
||||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
appsCount: number = 0;
|
appsCount: number = 0;
|
||||||
|
|
||||||
private organizationId: OrganizationId = "" as OrganizationId;
|
protected organizationId: OrganizationId = "" as OrganizationId;
|
||||||
|
|
||||||
dataLastUpdated: Date | null = null;
|
dataLastUpdated: Date | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { TestBed } from "@angular/core/testing";
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
import {
|
import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
AllActivitiesService,
|
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
ApplicationHealthReportDetailEnriched,
|
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
|
||||||
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
|
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
|
||||||
import { ToastService } from "@bitwarden/components";
|
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||||
|
|
||||||
@@ -16,18 +11,14 @@ import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service
|
|||||||
|
|
||||||
describe("AccessIntelligenceSecurityTasksService", () => {
|
describe("AccessIntelligenceSecurityTasksService", () => {
|
||||||
let service: AccessIntelligenceSecurityTasksService;
|
let service: AccessIntelligenceSecurityTasksService;
|
||||||
const defaultAdminTaskServiceSpy = mock<DefaultAdminTaskService>();
|
const defaultAdminTaskServiceMock = mock<DefaultAdminTaskService>();
|
||||||
const allActivitiesServiceSpy = mock<AllActivitiesService>();
|
const securityTasksApiServiceMock = mock<SecurityTasksApiService>();
|
||||||
const toastServiceSpy = mock<ToastService>();
|
|
||||||
const i18nServiceSpy = mock<I18nService>();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
service = new AccessIntelligenceSecurityTasksService(
|
service = new AccessIntelligenceSecurityTasksService(
|
||||||
allActivitiesServiceSpy,
|
defaultAdminTaskServiceMock,
|
||||||
defaultAdminTaskServiceSpy,
|
securityTasksApiServiceMock,
|
||||||
toastServiceSpy,
|
|
||||||
i18nServiceSpy,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,104 +27,48 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("assignTasks", () => {
|
describe("assignTasks", () => {
|
||||||
it("should call requestPasswordChange and setTaskCreatedCount", async () => {
|
it("should call requestPasswordChangeForCriticalApplications and setTaskCreatedCount", async () => {
|
||||||
|
// Set up test data
|
||||||
const organizationId = "org-1" as OrganizationId;
|
const organizationId = "org-1" as OrganizationId;
|
||||||
const apps = [
|
const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId];
|
||||||
{
|
const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications");
|
||||||
isMarkedAsCritical: true,
|
|
||||||
atRiskPasswordCount: 1,
|
// Call the method
|
||||||
atRiskCipherIds: ["cid1"],
|
await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds);
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
];
|
// Verify that the method was called with correct parameters
|
||||||
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2);
|
expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds);
|
||||||
await service.assignTasks(organizationId, apps);
|
|
||||||
expect(spy).toHaveBeenCalledWith(organizationId, apps);
|
|
||||||
expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("requestPasswordChange", () => {
|
describe("requestPasswordChangeForCriticalApplications", () => {
|
||||||
it("should create tasks for distinct cipher ids and show success toast", async () => {
|
it("should create tasks for distinct cipher ids and show success toast", async () => {
|
||||||
|
// Set up test data
|
||||||
const organizationId = "org-2" as OrganizationId;
|
const organizationId = "org-2" as OrganizationId;
|
||||||
const apps = [
|
const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId];
|
||||||
{
|
defaultAdminTaskServiceMock.bulkCreateTasks.mockResolvedValue(undefined);
|
||||||
isMarkedAsCritical: true,
|
const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications");
|
||||||
atRiskPasswordCount: 2,
|
|
||||||
atRiskCipherIds: ["cid1", "cid2"],
|
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
{
|
|
||||||
isMarkedAsCritical: true,
|
|
||||||
atRiskPasswordCount: 1,
|
|
||||||
atRiskCipherIds: ["cid2"],
|
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
];
|
|
||||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
|
|
||||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
|
||||||
|
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
// Call the method
|
||||||
|
await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds);
|
||||||
|
|
||||||
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [
|
// Verify that bulkCreateTasks was called with distinct cipher ids
|
||||||
|
expect(defaultAdminTaskServiceMock.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [
|
||||||
{ cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential },
|
{ cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential },
|
||||||
{ cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential },
|
{ cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential },
|
||||||
]);
|
]);
|
||||||
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({
|
// Verify that the method was called with correct parameters
|
||||||
message: "notifiedMembers",
|
expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds);
|
||||||
variant: "success",
|
|
||||||
title: "success",
|
|
||||||
});
|
|
||||||
expect(result).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show error toast and return 0 if bulkCreateTasks throws", async () => {
|
it("should handle error if defaultAdminTaskService errors", async () => {
|
||||||
const organizationId = "org-3" as OrganizationId;
|
const organizationId = "org-3" as OrganizationId;
|
||||||
const apps = [
|
const mockCipherIds = ["cid3" as CipherId];
|
||||||
{
|
defaultAdminTaskServiceMock.bulkCreateTasks.mockRejectedValue(new Error("API fail error"));
|
||||||
isMarkedAsCritical: true,
|
|
||||||
atRiskPasswordCount: 1,
|
|
||||||
atRiskCipherIds: ["cid3"],
|
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
];
|
|
||||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
|
|
||||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
|
||||||
|
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
await expect(
|
||||||
|
service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds),
|
||||||
expect(toastServiceSpy.showToast).toHaveBeenCalledWith({
|
).rejects.toThrow("API fail error");
|
||||||
message: "unexpectedError",
|
|
||||||
variant: "error",
|
|
||||||
title: "error",
|
|
||||||
});
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not create any tasks if no apps have atRiskPasswordCount > 0", async () => {
|
|
||||||
const organizationId = "org-4" as OrganizationId;
|
|
||||||
const apps = [
|
|
||||||
{
|
|
||||||
isMarkedAsCritical: true,
|
|
||||||
atRiskPasswordCount: 0,
|
|
||||||
atRiskCipherIds: ["cid4"],
|
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
];
|
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
|
||||||
|
|
||||||
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not create any tasks for non-critical apps", async () => {
|
|
||||||
const organizationId = "org-5" as OrganizationId;
|
|
||||||
const apps = [
|
|
||||||
{
|
|
||||||
isMarkedAsCritical: false,
|
|
||||||
atRiskPasswordCount: 2,
|
|
||||||
atRiskCipherIds: ["cid5", "cid6"],
|
|
||||||
} as ApplicationHealthReportDetailEnriched,
|
|
||||||
];
|
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
|
||||||
|
|
||||||
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,64 +1,63 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import {
|
import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
AllActivitiesService,
|
|
||||||
ApplicationHealthReportDetailEnriched,
|
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
|
import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks";
|
||||||
import { ToastService } from "@bitwarden/components";
|
|
||||||
|
|
||||||
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
|
import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction";
|
||||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||||
|
|
||||||
@Injectable()
|
/**
|
||||||
|
* Service for managing security tasks related to Access Intelligence features
|
||||||
|
*/
|
||||||
export class AccessIntelligenceSecurityTasksService {
|
export class AccessIntelligenceSecurityTasksService {
|
||||||
|
private _tasksSubject$ = new BehaviorSubject<SecurityTask[]>([]);
|
||||||
|
tasks$ = this._tasksSubject$.asObservable();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private allActivitiesService: AllActivitiesService,
|
|
||||||
private adminTaskService: DefaultAdminTaskService,
|
private adminTaskService: DefaultAdminTaskService,
|
||||||
private toastService: ToastService,
|
private securityTasksApiService: SecurityTasksApiService,
|
||||||
private i18nService: I18nService,
|
|
||||||
) {}
|
) {}
|
||||||
async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) {
|
|
||||||
const taskCount = await this.requestPasswordChange(organizationId, apps);
|
/**
|
||||||
this.allActivitiesService.setTaskCreatedCount(taskCount);
|
* Gets security task metrics for the given organization
|
||||||
|
*
|
||||||
|
* @param organizationId The organization ID
|
||||||
|
* @returns Metrics about security tasks such as a count of completed and total tasks
|
||||||
|
*/
|
||||||
|
getTaskMetrics(organizationId: OrganizationId) {
|
||||||
|
return this.securityTasksApiService.getTaskMetrics(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this method is shared between here and critical-applications.component.ts
|
/**
|
||||||
async requestPasswordChange(
|
* Loads security tasks for the given organization and updates the internal tasks subject
|
||||||
|
*
|
||||||
|
* @param organizationId The organization ID
|
||||||
|
*/
|
||||||
|
async loadTasks(organizationId: OrganizationId): Promise<void> {
|
||||||
|
// Loads the tasks to update the service
|
||||||
|
const tasks = await this.securityTasksApiService.getAllTasks(organizationId);
|
||||||
|
this._tasksSubject$.next(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk assigns password change tasks for critical applications with at-risk passwords
|
||||||
|
*
|
||||||
|
* @param organizationId The organization ID
|
||||||
|
* @param criticalApplicationIds IDs of critical applications with at-risk passwords
|
||||||
|
*/
|
||||||
|
async requestPasswordChangeForCriticalApplications(
|
||||||
organizationId: OrganizationId,
|
organizationId: OrganizationId,
|
||||||
apps: ApplicationHealthReportDetailEnriched[],
|
criticalApplicationIds: CipherId[],
|
||||||
): Promise<number> {
|
) {
|
||||||
// Only create tasks for CRITICAL applications with at-risk passwords
|
const distinctCipherIds = Array.from(new Set(criticalApplicationIds));
|
||||||
const cipherIds = apps
|
|
||||||
.filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0)
|
|
||||||
.flatMap((app) => app.atRiskCipherIds);
|
|
||||||
|
|
||||||
const distinctCipherIds = Array.from(new Set(cipherIds));
|
|
||||||
|
|
||||||
const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({
|
const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({
|
||||||
cipherId: cipherId as CipherId,
|
cipherId,
|
||||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
await this.adminTaskService.bulkCreateTasks(organizationId, tasks);
|
||||||
await this.adminTaskService.bulkCreateTasks(organizationId, tasks);
|
// Reload tasks after creation
|
||||||
this.toastService.showToast({
|
await this.loadTasks(organizationId);
|
||||||
message: this.i18nService.t("notifiedMembers"),
|
|
||||||
variant: "success",
|
|
||||||
title: this.i18nService.t("success"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return tasks.length;
|
|
||||||
} catch {
|
|
||||||
this.toastService.showToast({
|
|
||||||
message: this.i18nService.t("unexpectedError"),
|
|
||||||
variant: "error",
|
|
||||||
title: this.i18nService.t("error"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user