mirror of
https://github.com/bitwarden/browser
synced 2026-02-07 04:03:29 +00:00
Update some components to use the new risk-insights or application signal stores
This commit is contained in:
@@ -36,7 +36,7 @@ export class CriticalAppsService {
|
||||
|
||||
private fetchOrg$ = this.orgId
|
||||
.pipe(
|
||||
switchMap((orgId) => this.retrieveCriticalApps(orgId)),
|
||||
switchMap((orgId) => this._retrieveCriticalApps(orgId)),
|
||||
takeUntil(this.teardown),
|
||||
)
|
||||
.subscribe((apps) => this.criticalAppsList.next(apps));
|
||||
@@ -48,7 +48,7 @@ export class CriticalAppsService {
|
||||
) {}
|
||||
|
||||
// Get a list of critical apps for a given organization
|
||||
getAppsListForOrg(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
generateAppsListForOrg$(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
return this.criticalAppsList
|
||||
.asObservable()
|
||||
.pipe(map((apps) => apps.filter((app) => app.organizationId === orgId)));
|
||||
@@ -67,8 +67,8 @@ export class CriticalAppsService {
|
||||
}
|
||||
|
||||
// only save records that are not already in the database
|
||||
const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls);
|
||||
const criticalAppsRequests = await this.encryptNewEntries(
|
||||
const newEntries = await this._filterNewEntries(orgId as OrganizationId, selectedUrls);
|
||||
const criticalAppsRequests = await this._encryptNewEntries(
|
||||
orgId as OrganizationId,
|
||||
key,
|
||||
newEntries,
|
||||
@@ -94,6 +94,7 @@ export class CriticalAppsService {
|
||||
}
|
||||
}
|
||||
this.criticalAppsList.next(updatedList);
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
// Get the critical apps for a given organization
|
||||
@@ -120,7 +121,7 @@ export class CriticalAppsService {
|
||||
this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl));
|
||||
}
|
||||
|
||||
private retrieveCriticalApps(
|
||||
private _retrieveCriticalApps(
|
||||
orgId: OrganizationId | null,
|
||||
): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
if (orgId === null) {
|
||||
@@ -149,7 +150,10 @@ export class CriticalAppsService {
|
||||
return result$ as Observable<PasswordHealthReportApplicationsResponse[]>;
|
||||
}
|
||||
|
||||
private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise<string[]> {
|
||||
private async _filterNewEntries(
|
||||
orgId: OrganizationId,
|
||||
selectedUrls: string[],
|
||||
): Promise<string[]> {
|
||||
return await firstValueFrom(this.criticalAppsList).then((criticalApps) => {
|
||||
const criticalAppsUri = criticalApps
|
||||
.filter((f) => f.organizationId === orgId)
|
||||
@@ -158,7 +162,7 @@ export class CriticalAppsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async encryptNewEntries(
|
||||
private async _encryptNewEntries(
|
||||
orgId: OrganizationId,
|
||||
key: OrgKey,
|
||||
newEntries: string[],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe
|
||||
// @ts-strict-ignore
|
||||
import { concatMap, first, from, map, Observable, zip } from "rxjs";
|
||||
import { BehaviorSubject, concatMap, first, from, map, mergeMap, Observable, zip } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -24,7 +24,21 @@ import {
|
||||
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
|
||||
/**
|
||||
* Service responsible for handling business logic and data transformation
|
||||
* related to risk insights reports. It collects, processes, and converts
|
||||
* raw data into structured domain models such as health reports, summaries,
|
||||
* and lists of at-risk items.
|
||||
*/
|
||||
export class RiskInsightsReportService {
|
||||
// Renamed to clarify this is the full report, not just "applications"
|
||||
private _applicationReportsSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
|
||||
|
||||
/**
|
||||
* Observable exposing the full application health reports.
|
||||
*/
|
||||
applicationReports$ = this._applicationReportsSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
private auditService: AuditService,
|
||||
@@ -32,6 +46,25 @@ export class RiskInsightsReportService {
|
||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetches the applications report and updates the applicationReportsSubject.
|
||||
* @param organizationId The ID of the organization.
|
||||
*/
|
||||
loadApplicationsReport(organizationId: string): void {
|
||||
this.generateApplicationsReport$(organizationId).subscribe({
|
||||
next: (reports: ApplicationHealthReportDetail[]) => {
|
||||
this._applicationReportsSubject.next(reports);
|
||||
},
|
||||
error: () => {
|
||||
this._applicationReportsSubject.next([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
refreshApplicationsReport(organizationId: string): void {
|
||||
this.loadApplicationsReport(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report data from raw cipher health data.
|
||||
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
|
||||
@@ -49,28 +82,12 @@ export class RiskInsightsReportService {
|
||||
map(([allCiphers, memberCiphers]) => {
|
||||
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
|
||||
dtl.cipherIds.map((c) =>
|
||||
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
|
||||
this._getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
|
||||
),
|
||||
);
|
||||
return [allCiphers, details] as const;
|
||||
}),
|
||||
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report data for raw cipher health broken out into the uris
|
||||
* Can be used in the raw data + members + uri diagnostic report
|
||||
* @param organizationId Id of the organization
|
||||
* @returns Cipher health report data flattened to the uris
|
||||
*/
|
||||
generateRawDataUriReport$(organizationId: string): Observable<CipherHealthReportUriDetail[]> {
|
||||
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
|
||||
const results$ = cipherHealthDetails$.pipe(
|
||||
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
|
||||
concatMap(([ciphers, flattenedDetails]) => this._getCipherDetails(ciphers, flattenedDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
@@ -84,13 +101,56 @@ export class RiskInsightsReportService {
|
||||
* @returns The all applications health report data
|
||||
*/
|
||||
generateApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
|
||||
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
|
||||
const results$ = cipherHealthUriReport$.pipe(
|
||||
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
|
||||
// Compose the observable pipeline directly for clarity and efficiency
|
||||
return from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
|
||||
mergeMap((allCiphers) =>
|
||||
from(this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId)).pipe(
|
||||
mergeMap((memberCiphers) => {
|
||||
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
|
||||
dtl.cipherIds.map((c) =>
|
||||
this._getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
|
||||
),
|
||||
);
|
||||
return from(this._getCipherDetails(allCiphers, details));
|
||||
}),
|
||||
),
|
||||
),
|
||||
map((cipherHealthDetails) =>
|
||||
cipherHealthDetails.flatMap((rpt) =>
|
||||
rpt.trimmedUris.map((u) => this._getFlattenedCipherDetails(rpt, u)),
|
||||
),
|
||||
),
|
||||
map((cipherHealthUriReport) =>
|
||||
this._aggregateApplicationHealthReports(cipherHealthUriReport),
|
||||
),
|
||||
first(),
|
||||
);
|
||||
}
|
||||
|
||||
return results$;
|
||||
/**
|
||||
* Aggregates cipher health URI details into application health report details.
|
||||
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private _aggregateApplicationHealthReports(
|
||||
cipherHealthUriReport: CipherHealthReportUriDetail[],
|
||||
): ApplicationHealthReportDetail[] {
|
||||
const appReports: ApplicationHealthReportDetail[] = [];
|
||||
cipherHealthUriReport.forEach((uri) => {
|
||||
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
|
||||
|
||||
let atRisk: boolean = false;
|
||||
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
|
||||
atRisk = true;
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
appReports.push(this._getApplicationReportDetail(uri, atRisk));
|
||||
} else {
|
||||
appReports[index] = this._getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
}
|
||||
});
|
||||
return appReports;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,10 +211,10 @@ export class RiskInsightsReportService {
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
): ApplicationHealthReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = this.getUniqueMembers(totalMembers);
|
||||
const uniqueMembers = this._getUniqueMembers(totalMembers);
|
||||
|
||||
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
|
||||
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
|
||||
const uniqueAtRiskMembers = this._getUniqueMembers(atRiskMembers);
|
||||
|
||||
return {
|
||||
totalMemberCount: uniqueMembers.length,
|
||||
@@ -171,16 +231,16 @@ export class RiskInsightsReportService {
|
||||
* @param memberDetails Org members
|
||||
* @returns Cipher password health data with trimmed uris and associated members
|
||||
*/
|
||||
private async getCipherDetails(
|
||||
private async _getCipherDetails(
|
||||
ciphers: CipherView[],
|
||||
memberDetails: MemberDetailsFlat[],
|
||||
): Promise<CipherHealthReportDetail[]> {
|
||||
const cipherHealthReports: CipherHealthReportDetail[] = [];
|
||||
const passwordUseMap = new Map<string, number>();
|
||||
const exposedDetails = await this.findExposedPasswords(ciphers);
|
||||
const exposedDetails = await this._findExposedPasswords(ciphers);
|
||||
for (const cipher of ciphers) {
|
||||
if (this.validateCipher(cipher)) {
|
||||
const weakPassword = this.findWeakPassword(cipher);
|
||||
if (this._validateCipher(cipher)) {
|
||||
const weakPassword = this._findWeakPassword(cipher);
|
||||
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
|
||||
// Store in the set and evaluate later
|
||||
if (passwordUseMap.has(cipher.login.password)) {
|
||||
@@ -198,7 +258,7 @@ export class RiskInsightsReportService {
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
|
||||
// Trim uris to host name and create the cipher health report
|
||||
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
|
||||
const cipherTrimmedUris = this._getTrimmedCipherUris(cipher);
|
||||
const cipherHealth = {
|
||||
...cipher,
|
||||
weakPasswordDetail: weakPassword,
|
||||
@@ -223,47 +283,20 @@ export class RiskInsightsReportService {
|
||||
* @param cipherHealthReport Cipher health report with uris and members
|
||||
* @returns Flattened cipher health details to uri
|
||||
*/
|
||||
private getCipherUriDetails(
|
||||
private _getCipherUriDetails(
|
||||
cipherHealthReport: CipherHealthReportDetail[],
|
||||
): CipherHealthReportUriDetail[] {
|
||||
return cipherHealthReport.flatMap((rpt) =>
|
||||
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
|
||||
rpt.trimmedUris.map((u) => this._getFlattenedCipherDetails(rpt, u)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||
* If the item is new, create and add the object with the flattened details
|
||||
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private getApplicationHealthReport(
|
||||
cipherHealthUriReport: CipherHealthReportUriDetail[],
|
||||
): ApplicationHealthReportDetail[] {
|
||||
const appReports: ApplicationHealthReportDetail[] = [];
|
||||
cipherHealthUriReport.forEach((uri) => {
|
||||
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
|
||||
|
||||
let atRisk: boolean = false;
|
||||
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
|
||||
atRisk = true;
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
appReports.push(this.getApplicationReportDetail(uri, atRisk));
|
||||
} else {
|
||||
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
}
|
||||
});
|
||||
return appReports;
|
||||
}
|
||||
|
||||
private async findExposedPasswords(ciphers: CipherView[]): Promise<ExposedPasswordDetail[]> {
|
||||
private async _findExposedPasswords(ciphers: CipherView[]): Promise<ExposedPasswordDetail[]> {
|
||||
const exposedDetails: ExposedPasswordDetail[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
ciphers.forEach((ciph) => {
|
||||
if (this.validateCipher(ciph)) {
|
||||
if (this._validateCipher(ciph)) {
|
||||
const promise = this.auditService
|
||||
.passwordLeaked(ciph.login.password)
|
||||
.then((exposedCount) => {
|
||||
@@ -283,8 +316,8 @@ export class RiskInsightsReportService {
|
||||
return exposedDetails;
|
||||
}
|
||||
|
||||
private findWeakPassword(cipher: CipherView): WeakPasswordDetail {
|
||||
const hasUserName = this.isUserNameNotEmpty(cipher);
|
||||
private _findWeakPassword(cipher: CipherView): WeakPasswordDetail {
|
||||
const hasUserName = this._isUserNameNotEmpty(cipher);
|
||||
let userInput: string[] = [];
|
||||
if (hasUserName) {
|
||||
const atPosition = cipher.login.username.indexOf("@");
|
||||
@@ -313,14 +346,14 @@ export class RiskInsightsReportService {
|
||||
);
|
||||
|
||||
if (score != null && score <= 2) {
|
||||
const scoreValue = this.weakPasswordScore(score);
|
||||
const scoreValue = this._weakPasswordScore(score);
|
||||
const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail;
|
||||
return weakPasswordDetail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private weakPasswordScore(score: number): WeakPasswordScore {
|
||||
private _weakPasswordScore(score: number): WeakPasswordScore {
|
||||
switch (score) {
|
||||
case 4:
|
||||
return { label: "strong", badgeVariant: "success" };
|
||||
@@ -341,7 +374,7 @@ export class RiskInsightsReportService {
|
||||
* @param existingUriDetail The previously processed Uri item
|
||||
* @returns The new or updated application health report detail
|
||||
*/
|
||||
private getApplicationReportDetail(
|
||||
private _getApplicationReportDetail(
|
||||
newUriDetail: CipherHealthReportUriDetail,
|
||||
isAtRisk: boolean,
|
||||
existingUriDetail?: ApplicationHealthReportDetail,
|
||||
@@ -352,7 +385,7 @@ export class RiskInsightsReportService {
|
||||
: newUriDetail.trimmedUri,
|
||||
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
|
||||
memberDetails: existingUriDetail
|
||||
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
? this._getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
: newUriDetail.cipherMembers,
|
||||
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
|
||||
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
|
||||
@@ -365,7 +398,7 @@ export class RiskInsightsReportService {
|
||||
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
|
||||
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
|
||||
|
||||
reportDetail.atRiskMemberDetails = this.getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails = this._getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
|
||||
);
|
||||
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
|
||||
@@ -382,7 +415,7 @@ export class RiskInsightsReportService {
|
||||
* @param orgMembers Input list of members
|
||||
* @returns Distinct array of members
|
||||
*/
|
||||
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
|
||||
private _getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
|
||||
const existingEmails = new Set<string>();
|
||||
const distinctUsers = orgMembers.filter((member) => {
|
||||
if (existingEmails.has(member.email)) {
|
||||
@@ -394,7 +427,7 @@ export class RiskInsightsReportService {
|
||||
return distinctUsers;
|
||||
}
|
||||
|
||||
private getFlattenedCipherDetails(
|
||||
private _getFlattenedCipherDetails(
|
||||
detail: CipherHealthReportDetail,
|
||||
uri: string,
|
||||
): CipherHealthReportUriDetail {
|
||||
@@ -409,7 +442,7 @@ export class RiskInsightsReportService {
|
||||
};
|
||||
}
|
||||
|
||||
private getMemberDetailsFlat(
|
||||
private _getMemberDetailsFlat(
|
||||
userGuid: string,
|
||||
userName: string,
|
||||
email: string,
|
||||
@@ -433,7 +466,7 @@ export class RiskInsightsReportService {
|
||||
* @param cipher
|
||||
* @returns distinct list of trimmed cipher uris
|
||||
*/
|
||||
private getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
private _getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
const cipherUris: string[] = [];
|
||||
const uris = cipher.login?.uris ?? [];
|
||||
uris.map((u: { uri: string }) => {
|
||||
@@ -445,7 +478,7 @@ export class RiskInsightsReportService {
|
||||
return cipherUris;
|
||||
}
|
||||
|
||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
||||
private _isUserNameNotEmpty(c: CipherView): boolean {
|
||||
return !Utils.isNullOrWhitespace(c.login.username);
|
||||
}
|
||||
|
||||
@@ -454,7 +487,7 @@ export class RiskInsightsReportService {
|
||||
* is not deleted, and the user can view the password
|
||||
* @param c the input cipher
|
||||
*/
|
||||
private validateCipher(c: CipherView): boolean {
|
||||
private _validateCipher(c: CipherView): boolean {
|
||||
const { type, login, isDeleted, viewPassword } = c;
|
||||
if (
|
||||
type !== CipherType.Login ||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<!-- Loading -->
|
||||
<div *ngIf="store.loading()">
|
||||
<tools-risk-insights-loading></tools-risk-insights-loading>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="tw-mt-4" *ngIf="!store.loading() && !store.applicationsWithCriticalFlag().length">
|
||||
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
|
||||
<!-- Title -->
|
||||
<ng-container slot="title">
|
||||
<h2 class="tw-font-semibold tw-mt-4">
|
||||
{{ "noAppsInOrgTitle" | i18n: store.currentOrganization()?.name }}
|
||||
</h2>
|
||||
</ng-container>
|
||||
<!-- Description -->
|
||||
<ng-container slot="description">
|
||||
<div class="tw-flex tw-flex-col tw-mb-2">
|
||||
<span class="tw-text-muted">
|
||||
{{ "noAppsInOrgDescription" | i18n }}
|
||||
</span>
|
||||
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
|
||||
<!-- Cards for stats -->
|
||||
<div
|
||||
class="tw-mt-4 tw-flex tw-flex-col"
|
||||
*ngIf="!store.loading() && store.applicationsWithCriticalFlag().length"
|
||||
>
|
||||
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
||||
<div class="tw-flex tw-gap-6">
|
||||
<dirt-card
|
||||
#allAppsOrgAtRiskMembers
|
||||
class="tw-flex-1 tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': store.drawerInvokerId() === 'allAppsOrgAtRiskMembers' }"
|
||||
[title]="'atRiskMembers' | i18n"
|
||||
[value]="store.summary().totalAtRiskMemberCount"
|
||||
[maxValue]="store.summary().totalMemberCount"
|
||||
(click)="store.showAllAtRiskMembers()"
|
||||
>
|
||||
</dirt-card>
|
||||
<dirt-card
|
||||
#allAppsOrgAtRiskApplications
|
||||
class="tw-flex-1 tw-cursor-pointer"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-100': store.drawerInvokerId() === 'allAppsOrgAtRiskApplications',
|
||||
}"
|
||||
[title]="'atRiskApplications' | i18n"
|
||||
[value]="store.summary().totalAtRiskApplicationCount"
|
||||
[maxValue]="store.summary().totalApplicationCount"
|
||||
(click)="store.showAtRiskApplications()"
|
||||
>
|
||||
</dirt-card>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
|
||||
<bit-search
|
||||
[placeholder]="'searchApps' | i18n"
|
||||
class="tw-grow"
|
||||
[formControl]="searchControl"
|
||||
></bit-search>
|
||||
<button
|
||||
type="button"
|
||||
[buttonType]="'primary'"
|
||||
bitButton
|
||||
[disabled]="!store.selectedEntityIds().size"
|
||||
[loading]="store.isMarkingAppsAsCritical()"
|
||||
(click)="store.markAppsAsCritical()"
|
||||
>
|
||||
<i
|
||||
class="bwi tw-mr-2"
|
||||
[ngClass]="store.selectedEntityIds().size ? 'bwi-star-f' : 'bwi-star'"
|
||||
></i>
|
||||
{{ "markAppAsCritical" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table with all the application data -->
|
||||
<app-table-row-scrollable
|
||||
[dataSource]="dataSource"
|
||||
[showRowCheckBox]="true"
|
||||
[showRowMenuForCriticalApps]="false"
|
||||
[selectedUrls]="store.selectedApplicationsIds()"
|
||||
[selectedApplication]="store.drawerInvokerId()"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
[showAppAtRiskMembers]="store.showAtRiskApplicationMembers"
|
||||
></app-table-row-scrollable>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, DestroyRef, effect, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { debounceTime } from "rxjs";
|
||||
|
||||
import { ApplicationHealthReportDetailWithCriticalFlag } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import {
|
||||
IconButtonModule,
|
||||
Icons,
|
||||
NoItemsModule,
|
||||
SearchModule,
|
||||
TableDataSource,
|
||||
} from "@bitwarden/components";
|
||||
import { CardComponent } from "@bitwarden/dirt-card";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import { AppTableRowScrollableComponent } from "../app-table-row-scrollable/app-table-row-scrollable.component";
|
||||
import { ApplicationsLoadingComponent } from "../risk-insights-loading/risk-insights-loading.component";
|
||||
import { RiskInsightsStore } from "../risk-insights.store";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "tools-all-applications-with-signals",
|
||||
templateUrl: "./all-applications.component.html",
|
||||
imports: [
|
||||
ApplicationsLoadingComponent,
|
||||
HeaderModule,
|
||||
SearchModule,
|
||||
PipesModule,
|
||||
NoItemsModule,
|
||||
SharedModule,
|
||||
CardComponent,
|
||||
AppTableRowScrollableComponent,
|
||||
IconButtonModule,
|
||||
],
|
||||
// provide the store at the component level (can be done at the root level as well)
|
||||
})
|
||||
export class AllApplicationsComponentWithSignals {
|
||||
readonly store = inject(RiskInsightsStore);
|
||||
|
||||
protected dataSource = new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
|
||||
noItemsIcon = Icons.Security;
|
||||
destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
const applicationsWithCriticalFlag = this.store.applicationsWithCriticalFlag;
|
||||
|
||||
effect(() => {
|
||||
// Update table data when data changes
|
||||
this.dataSource.data = applicationsWithCriticalFlag();
|
||||
});
|
||||
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.subscribe((v) => (this.dataSource.filter = v));
|
||||
}
|
||||
|
||||
onCheckboxChange = (applicationName: string, event: Event) => {
|
||||
const isChecked = (event.target as HTMLInputElement).checked;
|
||||
if (isChecked) {
|
||||
this.store.selectApplication(applicationName);
|
||||
} else {
|
||||
this.store.deselectApplication(applicationName);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { computed, effect, inject } from "@angular/core";
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withFeature,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from "@ngrx/signals";
|
||||
import { EntityId } from "@ngrx/signals/entities";
|
||||
|
||||
import {
|
||||
RiskInsightsDataService,
|
||||
RiskInsightsReportService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import {
|
||||
AppAtRiskMembersDialogParams,
|
||||
ApplicationHealthReportSummary,
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { withActivatedRouteFeature } from "../../signal-store-features/activated-route.feature";
|
||||
import { withActiveAccountFeature } from "../../signal-store-features/active-account.feature";
|
||||
import {
|
||||
setMarkingCriticalApps,
|
||||
withCriticalApplicationsFeature,
|
||||
} from "../../signal-store-features/critical-applications.feature";
|
||||
import {
|
||||
closeDrawer,
|
||||
openDrawerForApplicationMembers,
|
||||
openDrawerForApplications,
|
||||
openDrawerForOrganizationMembers,
|
||||
withDrawerFeature,
|
||||
} from "../../signal-store-features/drawer.feature";
|
||||
import {
|
||||
setCurrentOrganizationId,
|
||||
withOrganizationFeature,
|
||||
} from "../../signal-store-features/organizations.feature";
|
||||
import { withRiskInsightsReportsFeature } from "../../signal-store-features/risk-insights-reports.feature";
|
||||
import {
|
||||
addSelectedEntityId,
|
||||
clearSelectedEntityIds,
|
||||
removeSelectedEntityId,
|
||||
} from "../../signal-store-features/selected-entities.feature";
|
||||
// Setup the initial state for the store
|
||||
|
||||
type ApplicationStoreState = {
|
||||
applicationSummary: ApplicationHealthReportSummary;
|
||||
initialized: boolean;
|
||||
isLoading: boolean;
|
||||
loadingCriticalApps: boolean;
|
||||
organization: any;
|
||||
atRiskAllMembers: AtRiskMemberDetail[];
|
||||
atRiskApplications: AtRiskApplicationDetail[];
|
||||
atRiskApplicationMembers: AppAtRiskMembersDialogParams | null;
|
||||
};
|
||||
|
||||
const initialState: ApplicationStoreState = {
|
||||
applicationSummary: {
|
||||
totalMemberCount: 0,
|
||||
totalAtRiskMemberCount: 0,
|
||||
totalApplicationCount: 0,
|
||||
totalAtRiskApplicationCount: 0,
|
||||
},
|
||||
initialized: false,
|
||||
isLoading: false,
|
||||
loadingCriticalApps: false,
|
||||
organization: undefined,
|
||||
atRiskAllMembers: [],
|
||||
atRiskApplications: [],
|
||||
atRiskApplicationMembers: null,
|
||||
};
|
||||
|
||||
export const ApplicationStore = signalStore(
|
||||
withState(initialState),
|
||||
withProps(() => ({
|
||||
toastService: inject(ToastService),
|
||||
dataService: inject(RiskInsightsDataService),
|
||||
reportService: inject(RiskInsightsReportService),
|
||||
i18nService: inject(I18nService),
|
||||
})),
|
||||
withDrawerFeature(), // Adds drawer functionality
|
||||
withActivatedRouteFeature(),
|
||||
withActiveAccountFeature(),
|
||||
withFeature(({ activeAccountUserId }) => withOrganizationFeature(activeAccountUserId)),
|
||||
withFeature(({ currentOrganizationId }) =>
|
||||
withCriticalApplicationsFeature(currentOrganizationId),
|
||||
),
|
||||
withRiskInsightsReportsFeature(),
|
||||
// withFeature(({ currentOrganizationId }) => withRiskInsightsReportsFeature(currentOrganizationId)),
|
||||
withComputed(({ entities, criticalApps, selectedEntityIds, reportService }) => {
|
||||
return {
|
||||
// table data
|
||||
// Expose drawer invoker ID for the table to use
|
||||
// tableDataSource: computed(() => {
|
||||
// const tableDataSource =
|
||||
// new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
|
||||
|
||||
// tableDataSource.data = entities();
|
||||
// return tableDataSource;
|
||||
// }),
|
||||
selectedApplicationsIds: computed(() => {
|
||||
const stringIds = new Set<string>();
|
||||
selectedEntityIds().forEach((id: EntityId) => {
|
||||
stringIds.add(id as string);
|
||||
});
|
||||
return stringIds;
|
||||
}),
|
||||
applicationsCount: computed(() => entities().length),
|
||||
applicationsWithCriticalFlag: computed(() => {
|
||||
const apps = entities();
|
||||
return apps.map((app) => ({
|
||||
...app,
|
||||
isMarkedAsCritical: criticalApps()
|
||||
.map((ca) => ca.uri)
|
||||
.includes(app.applicationName),
|
||||
}));
|
||||
}),
|
||||
summary: computed(() => reportService.generateApplicationsSummary(entities())),
|
||||
};
|
||||
}),
|
||||
withMethods(({ dataService, reportService, i18nService, toastService, ...store }) => ({
|
||||
selectApplication(id: EntityId): void {
|
||||
patchState(store, addSelectedEntityId(store.selectedEntityIds(), id));
|
||||
},
|
||||
deselectApplication(id: EntityId): void {
|
||||
patchState(store, removeSelectedEntityId(store.selectedEntityIds(), id));
|
||||
},
|
||||
isDrawerOpenForTableRow(applicationName: string): boolean {
|
||||
return store.drawerInvokerId() === applicationName;
|
||||
},
|
||||
markAppsAsCritical: async () => {
|
||||
patchState(store, setMarkingCriticalApps(true));
|
||||
|
||||
try {
|
||||
await store.saveCriticalApps(
|
||||
// store.currentOrganizationId(),
|
||||
Array.from(store.selectedApplicationsIds()),
|
||||
);
|
||||
|
||||
// Use the toast feature from the store
|
||||
toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: i18nService.t("applicationsMarkedAsCriticalSuccess"),
|
||||
});
|
||||
patchState(store, clearSelectedEntityIds());
|
||||
} finally {
|
||||
patchState(store, setMarkingCriticalApps(false));
|
||||
}
|
||||
},
|
||||
closeDrawer: () => {
|
||||
patchState(store, closeDrawer());
|
||||
},
|
||||
showAllAtRiskMembers: () => {
|
||||
const atRiskAllMembers = reportService.generateAtRiskMemberList(store.entities());
|
||||
patchState(store, { atRiskAllMembers }, openDrawerForOrganizationMembers());
|
||||
},
|
||||
showAtRiskApplications: () => {
|
||||
// TODO: This should be moved to the report service
|
||||
const atRiskApplications = reportService.generateAtRiskApplicationList(store.entities());
|
||||
patchState(store, { atRiskApplications }, openDrawerForApplications());
|
||||
},
|
||||
showAtRiskApplicationMembers: (applicationName: string) => {
|
||||
const atRiskApplicationMembers = {
|
||||
members:
|
||||
store.entities().find((app: any) => app.applicationName === applicationName)
|
||||
?.atRiskMemberDetails ?? [],
|
||||
applicationName,
|
||||
};
|
||||
patchState(
|
||||
store,
|
||||
{
|
||||
atRiskApplicationMembers,
|
||||
},
|
||||
openDrawerForApplicationMembers(applicationName),
|
||||
);
|
||||
},
|
||||
})),
|
||||
|
||||
withHooks({
|
||||
onInit(store) {
|
||||
// Watch for changes in the route params for organizationId
|
||||
effect(() => {
|
||||
const orgIdParam = store.activatedRouteParams()?.get("organizationId");
|
||||
if (orgIdParam) {
|
||||
patchState(store, setCurrentOrganizationId(orgIdParam));
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -16,11 +16,11 @@
|
||||
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container slot="button">
|
||||
<!-- <ng-container slot="button">
|
||||
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button">
|
||||
{{ "createNewLoginItem" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container> -->
|
||||
</bit-no-items>
|
||||
</div>
|
||||
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!(isLoading$ | async) && dataSource.data.length">
|
||||
@@ -73,7 +73,7 @@
|
||||
[showRowCheckBox]="true"
|
||||
[showRowMenuForCriticalApps]="false"
|
||||
[selectedUrls]="selectedUrls"
|
||||
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
|
||||
[isRowSelected]="isDrawerOpenForTableRow"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||
></app-table-row-scrollable>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RiskInsightsReportService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
// ApplicationHealthReportDetail,
|
||||
ApplicationHealthReportDetailWithCriticalFlag,
|
||||
ApplicationHealthReportSummary,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
@@ -85,7 +85,7 @@ export class AllApplicationsComponent implements OnInit {
|
||||
|
||||
combineLatest([
|
||||
this.dataService.applications$,
|
||||
this.criticalAppsService.getAppsListForOrg(organizationId),
|
||||
this.criticalAppsService.generateAppsListForOrg$(organizationId),
|
||||
organization$,
|
||||
])
|
||||
.pipe(
|
||||
@@ -131,18 +131,18 @@ export class AllApplicationsComponent implements OnInit {
|
||||
.subscribe((v) => (this.dataSource.filter = v));
|
||||
}
|
||||
|
||||
goToCreateNewLoginItem = async () => {
|
||||
// TODO: implement
|
||||
this.toastService.showToast({
|
||||
variant: "warning",
|
||||
title: "",
|
||||
message: "Not yet implemented",
|
||||
});
|
||||
};
|
||||
// goToCreateNewLoginItem = async () => {
|
||||
// // TODO: implement
|
||||
// this.toastService.showToast({
|
||||
// variant: "warning",
|
||||
// title: "",
|
||||
// message: "Not yet implemented",
|
||||
// });
|
||||
// };
|
||||
|
||||
isMarkedAsCriticalItem(applicationName: string) {
|
||||
return this.selectedUrls.has(applicationName);
|
||||
}
|
||||
// isMarkedAsCriticalItem(applicationName: string) {
|
||||
// return this.selectedUrls.has(applicationName);
|
||||
// }
|
||||
|
||||
markAppsAsCritical = async () => {
|
||||
this.markingAsCritical = true;
|
||||
@@ -164,9 +164,9 @@ export class AllApplicationsComponent implements OnInit {
|
||||
}
|
||||
};
|
||||
|
||||
trackByFunction(_: number, item: ApplicationHealthReportDetail) {
|
||||
return item.applicationName;
|
||||
}
|
||||
// trackByFunction(_: number, item: ApplicationHealthReportDetail) {
|
||||
// return item.applicationName;
|
||||
// }
|
||||
|
||||
showAppAtRiskMembers = async (applicationName: string) => {
|
||||
const info = {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="showRowCheckBox"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
|
||||
>
|
||||
<input
|
||||
bitCheckbox
|
||||
@@ -27,7 +27,7 @@
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="!showRowCheckBox"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
|
||||
>
|
||||
<i class="bwi bwi-star-f" *ngIf="row.isMarkedAsCritical"></i>
|
||||
</td>
|
||||
@@ -36,33 +36,24 @@
|
||||
</td>
|
||||
<td
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keypress)="showAppAtRiskMembers(row.applicationName)"
|
||||
bitCell
|
||||
>
|
||||
<span>{{ row.applicationName }}</span>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
>
|
||||
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
|
||||
<span>
|
||||
{{ row.atRiskPasswordCount }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
>
|
||||
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
|
||||
<span>
|
||||
{{ row.passwordCount }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
>
|
||||
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
|
||||
<span>
|
||||
{{ row.atRiskMemberCount }}
|
||||
</span>
|
||||
@@ -70,14 +61,14 @@
|
||||
<td
|
||||
bitCell
|
||||
data-testid="total-membership"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
|
||||
>
|
||||
{{ row.memberCount }}
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="showRowMenuForCriticalApps"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
|
||||
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
|
||||
>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
|
||||
@@ -18,8 +18,12 @@ export class AppTableRowScrollableComponent {
|
||||
@Input() showRowMenuForCriticalApps: boolean = false;
|
||||
@Input() showRowCheckBox: boolean = false;
|
||||
@Input() selectedUrls: Set<string> = new Set<string>();
|
||||
@Input() isDrawerIsOpenForThisRecord!: (applicationName: string) => boolean;
|
||||
@Input() selectedApplication: string;
|
||||
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
|
||||
@Input() unmarkAsCriticalApp!: (applicationName: string) => void;
|
||||
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;
|
||||
|
||||
isRowSelected(applicationName: string): boolean {
|
||||
return this.selectedApplication === applicationName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
[dataSource]="dataSource"
|
||||
[showRowCheckBox]="false"
|
||||
[showRowMenuForCriticalApps]="true"
|
||||
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
|
||||
[selectedApplication]="dataService.drawerInvokerId"
|
||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||
[unmarkAsCriticalApp]="unmarkAsCriticalApp"
|
||||
></app-table-row-scrollable>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-t
|
||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||
import { AppTableRowScrollableComponent } from "../app-table-row-scrollable/app-table-row-scrollable.component";
|
||||
import { RiskInsightsTabType } from "../risk-insights.component";
|
||||
import { RiskInsightsStore } from "../risk-insights.store";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -64,6 +65,8 @@ export class CriticalApplicationsComponent implements OnInit {
|
||||
isNotificationsFeatureEnabled: boolean = false;
|
||||
enableRequestPasswordChange = false;
|
||||
|
||||
readonly store = inject(RiskInsightsStore);
|
||||
|
||||
async ngOnInit() {
|
||||
this.isNotificationsFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.EnableRiskInsightsNotifications,
|
||||
@@ -71,7 +74,7 @@ export class CriticalApplicationsComponent implements OnInit {
|
||||
this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? "";
|
||||
combineLatest([
|
||||
this.dataService.applications$,
|
||||
this.criticalAppsService.getAppsListForOrg(this.organizationId),
|
||||
this.criticalAppsService.generateAppsListForOrg$(this.organizationId),
|
||||
])
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
@@ -87,7 +90,7 @@ export class CriticalApplicationsComponent implements OnInit {
|
||||
.subscribe((applications) => {
|
||||
if (applications) {
|
||||
this.dataSource.data = applications;
|
||||
this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
|
||||
// this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
|
||||
this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0;
|
||||
}
|
||||
});
|
||||
@@ -192,7 +195,4 @@ export class CriticalApplicationsComponent implements OnInit {
|
||||
trackByFunction(_: number, item: ApplicationHealthReportDetailWithCriticalFlag) {
|
||||
return item.applicationName;
|
||||
}
|
||||
isDrawerOpenForTableRow = (applicationName: string) => {
|
||||
return this.dataService.drawerInvokerId === applicationName;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<bit-container>
|
||||
<p>{{ "passwordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="!loading">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="hostURI">{{ "application" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "totalMembers" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<ng-container>
|
||||
<span>{{ row.hostURI }}</span>
|
||||
</ng-container>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span
|
||||
bitBadge
|
||||
*ngIf="passwordStrengthMap.has(row.id)"
|
||||
[variant]="passwordStrengthMap.get(row.id)[1]"
|
||||
>
|
||||
{{ passwordStrengthMap.get(row.id)[0] | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge *ngIf="exposedPasswordMap.has(row.id)" variant="warning">
|
||||
{{ "exposedXTimes" | i18n: exposedPasswordMap.get(row.id) }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right" data-testid="total-membership">
|
||||
{{ totalMembersMap.get(row.id) || 0 }}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</div>
|
||||
</bit-container>
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, convertToParamMap } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
MemberCipherDetailsApiService,
|
||||
PasswordHealthService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TableModule } from "@bitwarden/components";
|
||||
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import { PasswordHealthMembersURIComponent } from "./password-health-members-uri.component";
|
||||
|
||||
describe("PasswordHealthMembersUriComponent", () => {
|
||||
let component: PasswordHealthMembersURIComponent;
|
||||
let fixture: ComponentFixture<PasswordHealthMembersURIComponent>;
|
||||
let cipherServiceMock: MockProxy<CipherService>;
|
||||
const passwordHealthServiceMock = mock<PasswordHealthService>();
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
|
||||
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherServiceMock = mock<CipherService>();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PasswordHealthMembersURIComponent, PipesModule, TableModule, LooseComponentsModule],
|
||||
providers: [
|
||||
{ provide: CipherService, useValue: cipherServiceMock },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: AuditService, useValue: mock<AuditService>() },
|
||||
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
|
||||
{ provide: AccountService, useValue: mockAccountServiceWith(userId) },
|
||||
{
|
||||
provide: PasswordStrengthServiceAbstraction,
|
||||
useValue: mock<PasswordStrengthServiceAbstraction>(),
|
||||
},
|
||||
{ provide: PasswordHealthService, useValue: passwordHealthServiceMock },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(activeRouteParams),
|
||||
url: of([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MemberCipherDetailsApiService,
|
||||
useValue: mock<MemberCipherDetailsApiService>(),
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useValue: mock<ApiService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PasswordHealthMembersURIComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should initialize component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
MemberCipherDetailsApiService,
|
||||
PasswordHealthService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
BadgeVariant,
|
||||
ContainerComponent,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "tools-password-health-members-uri",
|
||||
templateUrl: "password-health-members-uri.component.html",
|
||||
imports: [
|
||||
BadgeModule,
|
||||
CommonModule,
|
||||
ContainerComponent,
|
||||
PipesModule,
|
||||
JslibModule,
|
||||
HeaderModule,
|
||||
TableModule,
|
||||
],
|
||||
providers: [PasswordHealthService, MemberCipherDetailsApiService],
|
||||
})
|
||||
export class PasswordHealthMembersURIComponent implements OnInit {
|
||||
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
||||
|
||||
weakPasswordCiphers: CipherView[] = [];
|
||||
|
||||
passwordUseMap = new Map<string, number>();
|
||||
|
||||
exposedPasswordMap = new Map<string, number>();
|
||||
|
||||
totalMembersMap = new Map<string, number>();
|
||||
|
||||
dataSource = new TableDataSource<CipherView>();
|
||||
|
||||
reportCiphers: (CipherView & { hostURI: string })[] = [];
|
||||
reportCipherURIs: string[] = [];
|
||||
|
||||
organization: Organization;
|
||||
|
||||
loading = true;
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
protected organizationService: OrganizationService,
|
||||
protected auditService: AuditService,
|
||||
protected i18nService: I18nService,
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(async (params) => {
|
||||
const organizationId = params.get("organizationId");
|
||||
await this.setCiphers(organizationId);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async setCiphers(organizationId: string) {
|
||||
const passwordHealthService = new PasswordHealthService(
|
||||
this.passwordStrengthService,
|
||||
this.auditService,
|
||||
this.cipherService,
|
||||
this.memberCipherDetailsApiService,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await passwordHealthService.generateReport();
|
||||
|
||||
this.dataSource.data = passwordHealthService.groupCiphersByLoginUri();
|
||||
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
|
||||
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
|
||||
this.passwordUseMap = passwordHealthService.passwordUseMap;
|
||||
this.totalMembersMap = passwordHealthService.totalMembersMap;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<p>{{ "passwordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col" *ngIf="!loading && dataSource.data.length">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="74">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "totalMembers" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="selectedIds.has(row.id)"
|
||||
(change)="onCheckboxChange(row.id, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span
|
||||
bitBadge
|
||||
*ngIf="passwordStrengthMap.has(row.id)"
|
||||
[variant]="passwordStrengthMap.get(row.id)[1]"
|
||||
>
|
||||
{{ passwordStrengthMap.get(row.id)[0] | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge *ngIf="exposedPasswordMap.has(row.id)" variant="warning">
|
||||
{{ "exposedXTimes" | i18n: exposedPasswordMap.get(row.id) }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right" data-testid="total-membership">
|
||||
{{ totalMembersMap.get(row.id) || 0 }}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</div>
|
||||
@@ -1,128 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, FormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { debounceTime, map } from "rxjs";
|
||||
|
||||
import {
|
||||
MemberCipherDetailsApiService,
|
||||
PasswordHealthService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeVariant,
|
||||
SearchModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "tools-password-health-members",
|
||||
templateUrl: "password-health-members.component.html",
|
||||
imports: [PipesModule, HeaderModule, SearchModule, FormsModule, SharedModule, TableModule],
|
||||
providers: [PasswordHealthService, MemberCipherDetailsApiService],
|
||||
})
|
||||
export class PasswordHealthMembersComponent implements OnInit {
|
||||
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
||||
|
||||
passwordUseMap = new Map<string, number>();
|
||||
|
||||
exposedPasswordMap = new Map<string, number>();
|
||||
|
||||
totalMembersMap = new Map<string, number>();
|
||||
|
||||
dataSource = new TableDataSource<CipherView>();
|
||||
|
||||
loading = true;
|
||||
|
||||
selectedIds: Set<number> = new Set<number>();
|
||||
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
protected auditService: AuditService,
|
||||
protected i18nService: I18nService,
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected toastService: ToastService,
|
||||
protected memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
) {
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.subscribe((v) => (this.dataSource.filter = v));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(async (params) => {
|
||||
const organizationId = params.get("organizationId");
|
||||
await this.setCiphers(organizationId);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async setCiphers(organizationId: string) {
|
||||
const passwordHealthService = new PasswordHealthService(
|
||||
this.passwordStrengthService,
|
||||
this.auditService,
|
||||
this.cipherService,
|
||||
this.memberCipherDetailsApiService,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await passwordHealthService.generateReport();
|
||||
|
||||
this.dataSource.data = passwordHealthService.reportCiphers;
|
||||
|
||||
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
|
||||
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
|
||||
this.passwordUseMap = passwordHealthService.passwordUseMap;
|
||||
this.totalMembersMap = passwordHealthService.totalMembersMap;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
markAppsAsCritical = async () => {
|
||||
// TODO: Send to API once implemented
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.selectedIds.clear();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("appsMarkedAsCritical"),
|
||||
});
|
||||
resolve(true);
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
|
||||
trackByFunction(_: number, item: CipherView) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
onCheckboxChange(id: number, event: Event) {
|
||||
const isChecked = (event.target as HTMLInputElement).checked;
|
||||
if (isChecked) {
|
||||
this.selectedIds.add(id);
|
||||
} else {
|
||||
this.selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<bit-container>
|
||||
<p>{{ "passwordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="!loading && dataSource.data.length">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span
|
||||
bitBadge
|
||||
*ngIf="row.weakPasswordDetail"
|
||||
[variant]="row.weakPasswordDetail?.detailValue.badgeVariant"
|
||||
>
|
||||
{{ row.weakPasswordDetail?.detailValue.label | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning" *ngIf="row.exposedPasswordDetail">
|
||||
{{ "exposedXTimes" | i18n: row.exposedPasswordDetail?.exposedXTimes }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</div>
|
||||
</bit-container>
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, convertToParamMap } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { TableModule } from "@bitwarden/components";
|
||||
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import { PasswordHealthComponent } from "./password-health.component";
|
||||
|
||||
describe("PasswordHealthComponent", () => {
|
||||
let component: PasswordHealthComponent;
|
||||
let fixture: ComponentFixture<PasswordHealthComponent>;
|
||||
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
|
||||
declarations: [],
|
||||
providers: [
|
||||
{ provide: RiskInsightsReportService, useValue: mock<RiskInsightsReportService>() },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(activeRouteParams),
|
||||
url: of([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PasswordHealthComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should initialize component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should call generateReport on init", () => {});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { CipherHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ContainerComponent,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { OrganizationBadgeModule } from "@bitwarden/web-vault/app/vault/individual-vault/organization-badge/organization-badge.module";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "tools-password-health",
|
||||
templateUrl: "password-health.component.html",
|
||||
imports: [
|
||||
BadgeModule,
|
||||
OrganizationBadgeModule,
|
||||
CommonModule,
|
||||
ContainerComponent,
|
||||
PipesModule,
|
||||
JslibModule,
|
||||
HeaderModule,
|
||||
TableModule,
|
||||
],
|
||||
})
|
||||
export class PasswordHealthComponent implements OnInit {
|
||||
passwordUseMap = new Map<string, number>();
|
||||
dataSource = new TableDataSource<CipherHealthReportDetail>();
|
||||
|
||||
loading = true;
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
protected riskInsightsReportService: RiskInsightsReportService,
|
||||
protected i18nService: I18nService,
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(async (params) => {
|
||||
const organizationId = params.get("organizationId");
|
||||
await this.setCiphers(organizationId);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async setCiphers(organizationId: string) {
|
||||
this.dataSource.data = await firstValueFrom(
|
||||
this.riskInsightsReportService.generateRawDataReport$(organizationId),
|
||||
);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ "reviewAtRiskPasswords" | i18n }}
|
||||
</div>
|
||||
<div
|
||||
*ngIf="dataLastUpdated$ | async"
|
||||
*ngIf="store.lastUpdated()"
|
||||
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
|
||||
>
|
||||
<i
|
||||
@@ -13,9 +13,9 @@
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-mx-4">{{
|
||||
"dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a")
|
||||
"dataLastUpdated" | i18n: (store.lastUpdated() | date: "MMMM d, y 'at' h:mm a")
|
||||
}}</span>
|
||||
<span class="tw-flex tw-justify-center tw-w-16">
|
||||
<!-- <span class="tw-flex tw-justify-center tw-w-16">
|
||||
<a
|
||||
*ngIf="!(isRefreshing$ | async)"
|
||||
bitButton
|
||||
@@ -31,56 +31,43 @@
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span> -->
|
||||
</div>
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
||||
<tools-all-applications></tools-all-applications>
|
||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: store.applicationsCount() }}">
|
||||
<tools-all-applications-with-signals></tools-all-applications-with-signals>
|
||||
</bit-tab>
|
||||
<bit-tab>
|
||||
<ng-template bitTabLabel>
|
||||
<i class="bwi bwi-star"></i>
|
||||
{{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }}
|
||||
{{ "criticalApplicationsWithCount" | i18n: store.criticalApps.length ?? 0 }}
|
||||
</ng-template>
|
||||
<tools-critical-applications></tools-critical-applications>
|
||||
</bit-tab>
|
||||
<bit-tab *ngIf="showDebugTabs" label="Raw Data">
|
||||
<tools-password-health></tools-password-health>
|
||||
</bit-tab>
|
||||
<bit-tab *ngIf="showDebugTabs" label="Raw Data + members">
|
||||
<tools-password-health-members></tools-password-health-members>
|
||||
</bit-tab>
|
||||
<bit-tab *ngIf="showDebugTabs" label="Raw Data + uri">
|
||||
<tools-password-health-members-uri></tools-password-health-members-uri>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
|
||||
<bit-drawer
|
||||
style="width: 30%"
|
||||
[(open)]="dataService.openDrawer"
|
||||
(openChange)="dataService.closeDrawer()"
|
||||
>
|
||||
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskMembers)">
|
||||
<bit-drawer style="width: 30%" [open]="store.drawerOpen()" (openChange)="store.closeDrawer()">
|
||||
<ng-container *ngIf="store.isOrganizationMembersDrawerActive()">
|
||||
<bit-drawer-header
|
||||
title="{{ 'atRiskMembersWithCount' | i18n: dataService.atRiskMemberDetails.length }}"
|
||||
title="{{ 'atRiskMembersWithCount' | i18n: store.atRiskAllMembers().length }}"
|
||||
>
|
||||
</bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
|
||||
(dataService.atRiskMemberDetails.length > 0
|
||||
(store.atRiskAllMembers().length > 0
|
||||
? "atRiskMembersDescription"
|
||||
: "atRiskMembersDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
<ng-container *ngIf="dataService.atRiskMemberDetails.length > 0">
|
||||
<ng-container *ngIf="store.atRiskAllMembers().length > 0">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">{{ "email" | i18n }}</div>
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let member of dataService.atRiskMemberDetails">
|
||||
<ng-container *ngFor="let member of store.atRiskAllMembers()">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ member.email }}</div>
|
||||
<div>{{ member.atRiskPasswordCount }}</div>
|
||||
@@ -90,43 +77,43 @@
|
||||
</bit-drawer-body>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)">
|
||||
<bit-drawer-header title="{{ dataService.appAtRiskMembers.applicationName }}">
|
||||
<ng-container *ngIf="store.isActiveDrawerType('ApplicationMembers')">
|
||||
<bit-drawer-header title="{{ store.atRiskApplicationMembers().applicationName }}">
|
||||
</bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<div bitTypography="body1" class="tw-mb-2">
|
||||
{{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }}
|
||||
{{ "atRiskMembersWithCount" | i18n: store.atRiskApplicationMembers().members.length }}
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
|
||||
{{
|
||||
(dataService.appAtRiskMembers.members.length > 0
|
||||
(store.atRiskApplicationMembers().members.length > 0
|
||||
? "atRiskMembersDescriptionWithApp"
|
||||
: "atRiskMembersDescriptionWithAppNone"
|
||||
) | i18n: dataService.appAtRiskMembers.applicationName
|
||||
) | i18n: store.atRiskApplicationMembers().applicationName
|
||||
}}
|
||||
</div>
|
||||
<div class="tw-mt-1">
|
||||
<ng-container *ngFor="let member of dataService.appAtRiskMembers.members">
|
||||
<ng-container *ngFor="let member of store.atRiskApplicationMembers().members">
|
||||
<div>{{ member.email }}</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</bit-drawer-body>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)">
|
||||
<ng-container *ngIf="store.isActiveDrawerType('Applications')">
|
||||
<bit-drawer-header
|
||||
title="{{ 'atRiskApplicationsWithCount' | i18n: dataService.atRiskAppDetails.length }}"
|
||||
title="{{ 'atRiskApplicationsWithCount' | i18n: store.atRiskApplications().length }}"
|
||||
>
|
||||
</bit-drawer-header>
|
||||
|
||||
<bit-drawer-body>
|
||||
<span bitTypography="body2" class="tw-text-muted tw-text-sm">{{
|
||||
(dataService.atRiskAppDetails.length > 0
|
||||
(store.atRiskApplications().length > 0
|
||||
? "atRiskApplicationsDescription"
|
||||
: "atRiskApplicationsDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
<ng-container *ngIf="dataService.atRiskAppDetails.length > 0">
|
||||
<ng-container *ngIf="store.atRiskApplications().length > 0">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "application" | i18n }}
|
||||
@@ -135,7 +122,7 @@
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let app of dataService.atRiskAppDetails">
|
||||
<ng-container *ngFor="let app of store.atRiskApplications()">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ app.applicationName }}</div>
|
||||
<div>{{ app.atRiskPasswordCount }}</div>
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { EMPTY, Observable } from "rxjs";
|
||||
import { map, switchMap } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
CriticalAppsService,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
DrawerType,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
@@ -29,11 +15,9 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
|
||||
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
||||
import { AllApplicationsComponentWithSignals } from "./all-applications-v2/all-applications.component";
|
||||
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||
import { PasswordHealthComponent } from "./password-health/password-health.component";
|
||||
import { PasswordHealthMembersComponent } from "./password-health-members/password-health-members.component";
|
||||
import { PasswordHealthMembersURIComponent } from "./password-health-members-uri/password-health-members-uri.component";
|
||||
import { RiskInsightsStore } from "./risk-insights.store";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
@@ -47,95 +31,49 @@ export enum RiskInsightsTabType {
|
||||
standalone: true,
|
||||
templateUrl: "./risk-insights.component.html",
|
||||
imports: [
|
||||
AllApplicationsComponent,
|
||||
AllApplicationsComponentWithSignals,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
CriticalApplicationsComponent,
|
||||
JslibModule,
|
||||
HeaderModule,
|
||||
PasswordHealthComponent,
|
||||
PasswordHealthMembersComponent,
|
||||
PasswordHealthMembersURIComponent,
|
||||
TabsModule,
|
||||
DrawerComponent,
|
||||
DrawerBodyComponent,
|
||||
DrawerHeaderComponent,
|
||||
LayoutComponent,
|
||||
],
|
||||
providers: [RiskInsightsStore],
|
||||
})
|
||||
export class RiskInsightsComponent implements OnInit {
|
||||
export class RiskInsightsComponent {
|
||||
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
|
||||
|
||||
dataLastUpdated: Date = new Date();
|
||||
|
||||
criticalApps$: Observable<PasswordHealthReportApplicationsResponse[]> = new Observable();
|
||||
showDebugTabs: boolean = false;
|
||||
|
||||
appsCount: number = 0;
|
||||
criticalAppsCount: number = 0;
|
||||
notifiedMembersCount: number = 0;
|
||||
|
||||
private organizationId: string | null = null;
|
||||
private destroyRef = inject(DestroyRef);
|
||||
isLoading$: Observable<boolean> = new Observable<boolean>();
|
||||
isRefreshing$: Observable<boolean> = new Observable<boolean>();
|
||||
dataLastUpdated$: Observable<Date | null> = new Observable<Date | null>();
|
||||
refetching: boolean = false;
|
||||
readonly store = inject(RiskInsightsStore);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private configService: ConfigService,
|
||||
protected dataService: RiskInsightsDataService,
|
||||
private criticalAppsService: CriticalAppsService,
|
||||
) {
|
||||
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => {
|
||||
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
|
||||
});
|
||||
const orgId = this.route.snapshot.paramMap.get("organizationId") ?? "";
|
||||
this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId);
|
||||
|
||||
const currentOrganizationId = this.store.currentOrganizationId;
|
||||
|
||||
// Initialize the data source with the store's application reports
|
||||
this.store.load(currentOrganizationId);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showDebugTabs = devFlagEnabled("showRiskInsightsDebug");
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map((params) => params.get("organizationId")),
|
||||
switchMap((orgId: string | null) => {
|
||||
if (orgId) {
|
||||
this.organizationId = orgId;
|
||||
this.dataService.fetchApplicationsReport(orgId);
|
||||
this.isLoading$ = this.dataService.isLoading$;
|
||||
this.isRefreshing$ = this.dataService.isRefreshing$;
|
||||
this.dataLastUpdated$ = this.dataService.dataLastUpdated$;
|
||||
return this.dataService.applications$;
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (applications: ApplicationHealthReportDetail[] | null) => {
|
||||
if (applications) {
|
||||
this.appsCount = applications.length;
|
||||
}
|
||||
this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the data by re-fetching the applications report.
|
||||
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
|
||||
*/
|
||||
refreshData(): void {
|
||||
if (this.organizationId) {
|
||||
this.dataService.refreshApplicationsReport(this.organizationId);
|
||||
}
|
||||
}
|
||||
// /**
|
||||
// * Refreshes the data by re-fetching the applications report.
|
||||
// * This will automatically notify child components subscribed to the RiskInsightsDataService observables.
|
||||
// */
|
||||
// refreshData(): void {
|
||||
// if (this.organizationId) {
|
||||
// this.store.load(this.organizationId);
|
||||
// }
|
||||
// }
|
||||
|
||||
async onTabChange(newIndex: number): Promise<void> {
|
||||
await this.router.navigate([], {
|
||||
@@ -145,11 +83,6 @@ export class RiskInsightsComponent implements OnInit {
|
||||
});
|
||||
|
||||
// close drawer when tabs are changed
|
||||
this.dataService.closeDrawer();
|
||||
}
|
||||
|
||||
// Get a list of drawer types
|
||||
get drawerTypes(): typeof DrawerType {
|
||||
return DrawerType;
|
||||
this.store.closeDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { computed, effect, inject, Signal } from "@angular/core";
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withFeature,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from "@ngrx/signals";
|
||||
import { EntityId } from "@ngrx/signals/entities";
|
||||
|
||||
import {
|
||||
RiskInsightsDataService,
|
||||
RiskInsightsReportService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import {
|
||||
AppAtRiskMembersDialogParams,
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { withActivatedRouteFeature } from "../signal-store-features/activated-route.feature";
|
||||
import { withActiveAccountFeature } from "../signal-store-features/active-account.feature";
|
||||
import {
|
||||
setMarkingCriticalApps,
|
||||
withCriticalApplicationsFeature,
|
||||
} from "../signal-store-features/critical-applications.feature";
|
||||
import {
|
||||
closeDrawer,
|
||||
openDrawerForApplicationMembers,
|
||||
openDrawerForApplications,
|
||||
openDrawerForOrganizationMembers,
|
||||
withDrawerFeature,
|
||||
} from "../signal-store-features/drawer.feature";
|
||||
import {
|
||||
setCurrentOrganizationId,
|
||||
withOrganizationFeature,
|
||||
} from "../signal-store-features/organizations.feature";
|
||||
import { withRiskInsightsReportsFeature } from "../signal-store-features/risk-insights-reports.feature";
|
||||
import {
|
||||
addSelectedEntityId,
|
||||
clearSelectedEntityIds,
|
||||
removeSelectedEntityId,
|
||||
} from "../signal-store-features/selected-entities.feature";
|
||||
// Setup the initial state for the store
|
||||
|
||||
type RiskInsightsStoreState = {
|
||||
initialized: boolean;
|
||||
organization: any;
|
||||
atRiskAllMembers: AtRiskMemberDetail[];
|
||||
atRiskApplications: AtRiskApplicationDetail[];
|
||||
atRiskApplicationMembers: AppAtRiskMembersDialogParams | null;
|
||||
};
|
||||
|
||||
const initialState: RiskInsightsStoreState = {
|
||||
initialized: false,
|
||||
organization: undefined,
|
||||
atRiskAllMembers: [],
|
||||
atRiskApplications: [],
|
||||
atRiskApplicationMembers: null,
|
||||
};
|
||||
|
||||
export const RiskInsightsStore = signalStore(
|
||||
withState(initialState),
|
||||
withProps(() => ({
|
||||
toastService: inject(ToastService),
|
||||
dataService: inject(RiskInsightsDataService),
|
||||
reportService: inject(RiskInsightsReportService),
|
||||
i18nService: inject(I18nService),
|
||||
})),
|
||||
withDrawerFeature(), // Adds drawer functionality
|
||||
withActivatedRouteFeature(),
|
||||
withActiveAccountFeature(),
|
||||
withFeature(({ activeAccountUserId }) => withOrganizationFeature(activeAccountUserId)),
|
||||
withFeature(({ currentOrganizationId }) =>
|
||||
withCriticalApplicationsFeature(currentOrganizationId),
|
||||
),
|
||||
withRiskInsightsReportsFeature(),
|
||||
withComputed(
|
||||
({
|
||||
entities,
|
||||
criticalApps,
|
||||
selectedEntityIds,
|
||||
reportService,
|
||||
loadingCriticalApps,
|
||||
loadingApplicationReports,
|
||||
}) => {
|
||||
return {
|
||||
selectedApplicationsIds: computed(() => {
|
||||
const stringIds = new Set<string>();
|
||||
selectedEntityIds().forEach((id: EntityId) => {
|
||||
stringIds.add(id as string);
|
||||
});
|
||||
return stringIds;
|
||||
}),
|
||||
applicationsCount: computed(() => entities().length),
|
||||
applicationsWithCriticalFlag: computed(() => {
|
||||
const apps = entities();
|
||||
return apps.map((app) => ({
|
||||
...app,
|
||||
isMarkedAsCritical: criticalApps()
|
||||
.map((ca) => ca.uri)
|
||||
.includes(app.applicationName),
|
||||
}));
|
||||
}),
|
||||
applicationReports: computed(() => entities()),
|
||||
summary: computed(() => reportService.generateApplicationsSummary(entities())),
|
||||
loading: computed(() => loadingCriticalApps() || loadingApplicationReports()),
|
||||
};
|
||||
},
|
||||
),
|
||||
withMethods(({ dataService, reportService, i18nService, toastService, ...store }) => ({
|
||||
selectApplication(id: EntityId): void {
|
||||
patchState(store, addSelectedEntityId(store.selectedEntityIds(), id));
|
||||
},
|
||||
deselectApplication(id: EntityId): void {
|
||||
patchState(store, removeSelectedEntityId(store.selectedEntityIds(), id));
|
||||
},
|
||||
isDrawerOpenForTableRow(applicationName: string): boolean {
|
||||
return store.drawerInvokerId() === applicationName;
|
||||
},
|
||||
markAppsAsCritical: async () => {
|
||||
patchState(store, setMarkingCriticalApps(true));
|
||||
|
||||
try {
|
||||
await store.saveCriticalApps(
|
||||
// store.currentOrganizationId(),
|
||||
Array.from(store.selectedApplicationsIds()),
|
||||
);
|
||||
|
||||
// Use the toast feature from the store
|
||||
toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: i18nService.t("applicationsMarkedAsCriticalSuccess"),
|
||||
});
|
||||
patchState(store, clearSelectedEntityIds());
|
||||
} finally {
|
||||
patchState(store, setMarkingCriticalApps(false));
|
||||
}
|
||||
},
|
||||
closeDrawer: () => {
|
||||
patchState(store, closeDrawer());
|
||||
},
|
||||
showAllAtRiskMembers: () => {
|
||||
const atRiskAllMembers = reportService.generateAtRiskMemberList(store.entities());
|
||||
patchState(store, { atRiskAllMembers }, openDrawerForOrganizationMembers());
|
||||
},
|
||||
showAtRiskApplications: () => {
|
||||
// TODO: This should be moved to the report service
|
||||
const atRiskApplications = reportService.generateAtRiskApplicationList(store.entities());
|
||||
patchState(store, { atRiskApplications }, openDrawerForApplications());
|
||||
},
|
||||
showAtRiskApplicationMembers: (applicationName: string) => {
|
||||
const atRiskApplicationMembers = {
|
||||
members:
|
||||
store.entities().find((app: any) => app.applicationName === applicationName)
|
||||
?.atRiskMemberDetails ?? [],
|
||||
applicationName,
|
||||
};
|
||||
patchState(
|
||||
store,
|
||||
{
|
||||
atRiskApplicationMembers,
|
||||
},
|
||||
openDrawerForApplicationMembers(applicationName),
|
||||
);
|
||||
},
|
||||
load: (orgId: Signal<string>) => {
|
||||
store.setOrganizationId(orgId);
|
||||
store.loadApplicationReports(orgId);
|
||||
store.loadCriticalApps(orgId);
|
||||
},
|
||||
})),
|
||||
|
||||
withHooks({
|
||||
onInit(store) {
|
||||
// Watch for changes in the route params for organizationId
|
||||
effect(() => {
|
||||
const orgIdParam = store.activatedRouteParams().get("organizationId");
|
||||
if (orgIdParam) {
|
||||
patchState(store, setCurrentOrganizationId(orgIdParam));
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { signalStoreFeature, withComputed } from "@ngrx/signals";
|
||||
|
||||
/**
|
||||
* Exposes values from the Angular ActivatedRoute
|
||||
*
|
||||
* @returns A feature that provides access to route parameters as a computed signal.
|
||||
*/
|
||||
export function withActivatedRouteFeature() {
|
||||
return signalStoreFeature(
|
||||
withComputed((_store) => {
|
||||
const activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
return {
|
||||
activatedRouteParams: toSignal(activatedRoute.paramMap),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { signalStoreFeature, withComputed } from "@ngrx/signals";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
/**
|
||||
* Exposes values from the `AccountService` as a feature for use in signal stores.
|
||||
*
|
||||
* @returns A feature that provides access to the active account and its user ID.
|
||||
*/
|
||||
export function withActiveAccountFeature() {
|
||||
return signalStoreFeature(
|
||||
withComputed((_store) => {
|
||||
const accountService = inject(AccountService);
|
||||
|
||||
return {
|
||||
activeAccount: toSignal(accountService.activeAccount$),
|
||||
activeAccountUserId: toSignal(getUserId(accountService.activeAccount$)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { inject, Signal } from "@angular/core";
|
||||
import { tapResponse } from "@ngrx/operators";
|
||||
import {
|
||||
PartialStateUpdater,
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from "@ngrx/signals";
|
||||
import { rxMethod } from "@ngrx/signals/rxjs-interop";
|
||||
import { exhaustMap, filter, from, pipe, tap } from "rxjs";
|
||||
|
||||
import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { PasswordHealthReportApplicationsResponse } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export type CriticalApplicationsFeatureState = {
|
||||
isMarkingAppsAsCritical: boolean;
|
||||
loadingCriticalApps: boolean;
|
||||
criticalApps: PasswordHealthReportApplicationsResponse[];
|
||||
};
|
||||
|
||||
export function setLoadingCriticalApps(
|
||||
isLoading: boolean,
|
||||
): PartialStateUpdater<CriticalApplicationsFeatureState> {
|
||||
return (state) => ({
|
||||
...state,
|
||||
loadingCriticalApps: isLoading,
|
||||
});
|
||||
}
|
||||
|
||||
export function setCriticalApps(
|
||||
applications: PasswordHealthReportApplicationsResponse[],
|
||||
): PartialStateUpdater<CriticalApplicationsFeatureState> {
|
||||
return (state) => ({
|
||||
...state,
|
||||
criticalApps: applications,
|
||||
});
|
||||
}
|
||||
|
||||
export function setMarkingCriticalApps(
|
||||
isMarking: boolean,
|
||||
): Partial<CriticalApplicationsFeatureState> {
|
||||
return { isMarkingAppsAsCritical: isMarking };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes values from the `CriticalApplicationsService` as a feature for use in signal stores.
|
||||
*
|
||||
* @returns A feature that provides access to the critical applications and.
|
||||
*/
|
||||
export function withCriticalApplicationsFeature(orgId: Signal<string>) {
|
||||
return signalStoreFeature(
|
||||
withState<CriticalApplicationsFeatureState>({
|
||||
isMarkingAppsAsCritical: false,
|
||||
loadingCriticalApps: false,
|
||||
criticalApps: [],
|
||||
}),
|
||||
withProps(() => ({
|
||||
criticalApplicationsService: inject(CriticalAppsService),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
setOrganizationId: rxMethod<string>(
|
||||
pipe(
|
||||
filter((orgId) => !!orgId),
|
||||
tap((orgId) => {
|
||||
return store.criticalApplicationsService.setOrganizationId(orgId as OrganizationId);
|
||||
}),
|
||||
),
|
||||
),
|
||||
loadCriticalApps: rxMethod<string>(
|
||||
exhaustMap((orgId) => {
|
||||
const org = orgId;
|
||||
// No organization ID provided, return empty state
|
||||
if (!org) {
|
||||
patchState(store, setCriticalApps([]));
|
||||
return from([]); // Return an empty observable
|
||||
}
|
||||
|
||||
patchState(store, setLoadingCriticalApps(true));
|
||||
return store.criticalApplicationsService.generateAppsListForOrg$(org).pipe(
|
||||
tapResponse({
|
||||
next: (applications) => {
|
||||
patchState(store, setCriticalApps(applications), setLoadingCriticalApps(false));
|
||||
},
|
||||
error: (err) => {
|
||||
patchState(store, setCriticalApps([]), setLoadingCriticalApps(false));
|
||||
// TODO: Handle error appropriately, e.g., show a toast notification
|
||||
// console.error(err);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
saveCriticalApps: rxMethod<string[]>(
|
||||
exhaustMap((selectedUrls) => {
|
||||
patchState(store, setMarkingCriticalApps(true));
|
||||
return from(
|
||||
store.criticalApplicationsService.setCriticalApps(orgId(), selectedUrls),
|
||||
).pipe(
|
||||
tapResponse({
|
||||
next: (response) => {
|
||||
patchState(store, setMarkingCriticalApps(false), setCriticalApps(response));
|
||||
},
|
||||
error: (err) => {
|
||||
patchState(store, setMarkingCriticalApps(false));
|
||||
// TODO: Handle error appropriately
|
||||
// console.error(err);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { computed } from "@angular/core";
|
||||
import { signalStoreFeature, withComputed, withMethods, withState } from "@ngrx/signals";
|
||||
|
||||
export type DrawerContext = "None" | "OrganizationMembers" | "ApplicationMembers" | "Applications";
|
||||
|
||||
export interface DrawerState {
|
||||
drawerOpen: boolean;
|
||||
drawerInvokerId: string;
|
||||
activeDrawerType: DrawerContext;
|
||||
}
|
||||
|
||||
export function openDrawerForOrganizationMembers(): DrawerState {
|
||||
return {
|
||||
drawerOpen: true,
|
||||
activeDrawerType: "OrganizationMembers",
|
||||
drawerInvokerId: "",
|
||||
};
|
||||
}
|
||||
export function openDrawerForApplicationMembers(drawerInvokerId: string = ""): DrawerState {
|
||||
return {
|
||||
drawerOpen: true,
|
||||
activeDrawerType: "ApplicationMembers",
|
||||
drawerInvokerId,
|
||||
};
|
||||
}
|
||||
export function openDrawerForApplications(drawerInvokerId: string = ""): DrawerState {
|
||||
return {
|
||||
drawerOpen: true,
|
||||
activeDrawerType: "Applications",
|
||||
drawerInvokerId,
|
||||
};
|
||||
}
|
||||
export function closeDrawer(): DrawerState {
|
||||
return {
|
||||
drawerOpen: false,
|
||||
activeDrawerType: "None",
|
||||
drawerInvokerId: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function withDrawerFeature() {
|
||||
return signalStoreFeature(
|
||||
withState<DrawerState>({
|
||||
drawerOpen: false,
|
||||
drawerInvokerId: "",
|
||||
activeDrawerType: "None",
|
||||
}),
|
||||
withComputed((store) => ({
|
||||
isApplicationDrawerActive: computed(() => store.activeDrawerType() === "Applications"),
|
||||
isApplicationMembersDrawerActive: computed(
|
||||
() => store.activeDrawerType() === "ApplicationMembers",
|
||||
),
|
||||
isOrganizationMembersDrawerActive: computed(
|
||||
() => store.activeDrawerType() === "OrganizationMembers",
|
||||
),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
isActiveDrawerType: (drawerType: DrawerContext): boolean => {
|
||||
return store.activeDrawerType() === drawerType;
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { inject, Signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { signalStoreFeature, withComputed, withProps, withState } from "@ngrx/signals";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export type OrganizationFeatureState = {
|
||||
currentOrganizationId: string;
|
||||
};
|
||||
|
||||
export function setCurrentOrganizationId(organizationId: string): OrganizationFeatureState {
|
||||
return { currentOrganizationId: organizationId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes values from the `Organization` as a feature for use in signal stores.
|
||||
*
|
||||
* @returns A feature that provides access to the active account and its user ID.
|
||||
*/
|
||||
export function withOrganizationFeature(activeAccountUserId: Signal<UserId>) {
|
||||
return signalStoreFeature(
|
||||
withState<OrganizationFeatureState>({ currentOrganizationId: "" }),
|
||||
withProps(() => ({
|
||||
organizationService: inject(OrganizationService),
|
||||
})),
|
||||
withComputed(({ currentOrganizationId, organizationService }) => {
|
||||
return {
|
||||
organizations: toSignal(organizationService.organizations$(activeAccountUserId())),
|
||||
currentOrganization: toSignal(
|
||||
organizationService
|
||||
.organizations$(activeAccountUserId())
|
||||
.pipe(getOrganizationById(currentOrganizationId())),
|
||||
),
|
||||
};
|
||||
}),
|
||||
// withMethods(({ organizationService }) => {
|
||||
// return {
|
||||
// getOrganizationById: (id: string) =>
|
||||
// organizationService.organizations$(activeAccountUserId()).pipe(getOrganizationById(id)),
|
||||
// };
|
||||
// }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { tapResponse } from "@ngrx/operators";
|
||||
import {
|
||||
PartialStateUpdater,
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from "@ngrx/signals";
|
||||
import { SelectEntityId, setAllEntities, withEntities } from "@ngrx/signals/entities";
|
||||
import { rxMethod } from "@ngrx/signals/rxjs-interop";
|
||||
import { exhaustMap, from } from "rxjs";
|
||||
|
||||
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
||||
|
||||
import { withSelectedEntitiesFeature } from "./selected-entities.feature";
|
||||
|
||||
export type RiskInsightsReportsFeatureState = {
|
||||
loadingApplicationReports: boolean;
|
||||
lastUpdated: Date | null;
|
||||
};
|
||||
|
||||
export function setLoadingApps(
|
||||
isLoading: boolean,
|
||||
): PartialStateUpdater<RiskInsightsReportsFeatureState> {
|
||||
return (state) => ({
|
||||
...state,
|
||||
loadingApplicationReports: isLoading,
|
||||
});
|
||||
}
|
||||
|
||||
export function setLastUpdated(
|
||||
lastUpdated: Date,
|
||||
): PartialStateUpdater<RiskInsightsReportsFeatureState> {
|
||||
return (state) => ({
|
||||
...state,
|
||||
lastUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
export const applicationSelectId: SelectEntityId<ApplicationHealthReportDetail> = (app) =>
|
||||
app.applicationName;
|
||||
|
||||
/**
|
||||
* Exposes values from the `RiskInsightsReportsService` as a feature for use in signal stores.
|
||||
*
|
||||
* @returns A feature that provides access to the application reports.
|
||||
*/
|
||||
export function withRiskInsightsReportsFeature() {
|
||||
return signalStoreFeature(
|
||||
withState<RiskInsightsReportsFeatureState>({
|
||||
loadingApplicationReports: false,
|
||||
lastUpdated: null,
|
||||
}),
|
||||
withEntities<ApplicationHealthReportDetail>(),
|
||||
withSelectedEntitiesFeature(),
|
||||
withProps(() => ({
|
||||
riskInsightsReportsService: inject(RiskInsightsReportService),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
loadApplicationReports: rxMethod<string>(
|
||||
exhaustMap((orgId) => {
|
||||
// No organization ID provided, return empty state
|
||||
if (!orgId) {
|
||||
patchState(store, setAllEntities([], { selectId: applicationSelectId }));
|
||||
}
|
||||
|
||||
patchState(store, setLoadingApps(true));
|
||||
return store.riskInsightsReportsService.generateApplicationsReport$(orgId).pipe(
|
||||
tapResponse({
|
||||
next: (applications) => {
|
||||
patchState(
|
||||
store,
|
||||
setAllEntities(applications, { selectId: applicationSelectId }),
|
||||
setLastUpdated(new Date()),
|
||||
setLoadingApps(false),
|
||||
);
|
||||
return from([]); // Return an empty observable
|
||||
},
|
||||
error: (err) => {
|
||||
patchState(
|
||||
store,
|
||||
setAllEntities([], { selectId: applicationSelectId }),
|
||||
setLoadingApps(false),
|
||||
);
|
||||
// TODO: Handle error appropriately, e.g., show a toast notification
|
||||
// console.error(err);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { computed } from "@angular/core";
|
||||
import { signalStoreFeature, type, withComputed, withState } from "@ngrx/signals";
|
||||
import { EntityId, EntityState } from "@ngrx/signals/entities";
|
||||
|
||||
type SelectedEntityIds = Set<EntityId>;
|
||||
export type SelectedEntityState = { selectedEntityIds: SelectedEntityIds };
|
||||
|
||||
// Add an ID to the set (returns new state)
|
||||
export function addSelectedEntityId(
|
||||
currentSelectedEntityIds: SelectedEntityIds,
|
||||
id: EntityId,
|
||||
): SelectedEntityState {
|
||||
return {
|
||||
selectedEntityIds: new Set([...currentSelectedEntityIds, id]),
|
||||
};
|
||||
}
|
||||
|
||||
// Remove an ID from the set (returns new state)
|
||||
export function removeSelectedEntityId(
|
||||
currentSelectedEntityIds: SelectedEntityIds,
|
||||
id: EntityId,
|
||||
): SelectedEntityState {
|
||||
const newSet = new Set(currentSelectedEntityIds);
|
||||
newSet.delete(id);
|
||||
return {
|
||||
selectedEntityIds: newSet,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSelectedEntityIds(): SelectedEntityState {
|
||||
return { selectedEntityIds: new Set<EntityId>() };
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature that provides the ability to manage multiple selected entities
|
||||
*
|
||||
* @returns A feature that provides selected entity state management.
|
||||
*/
|
||||
export function withSelectedEntitiesFeature<Entity>() {
|
||||
return signalStoreFeature(
|
||||
{ state: type<EntityState<Entity>>() },
|
||||
withState<SelectedEntityState>({ selectedEntityIds: new Set<EntityId>() }),
|
||||
withComputed(({ entityMap, selectedEntityIds }) => ({
|
||||
selectedEntities: computed(() => {
|
||||
const entities: Array<Entity> = [];
|
||||
selectedEntityIds().forEach((id) => {
|
||||
const entity = entityMap()[id];
|
||||
if (entity) {
|
||||
entities.push(entity);
|
||||
}
|
||||
});
|
||||
return entities;
|
||||
}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { computed } from "@angular/core";
|
||||
import { signalStoreFeature, type, withComputed, withState } from "@ngrx/signals";
|
||||
import { EntityId, EntityState } from "@ngrx/signals/entities";
|
||||
|
||||
export type SelectedEntityState = { selectedEntityId: EntityId | null };
|
||||
|
||||
export function setSelectedEntityId(id: EntityId | null): SelectedEntityState {
|
||||
return { selectedEntityId: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature that provides the ability to manage one selected entity
|
||||
*
|
||||
* @returns A feature that provides selected entity state management.
|
||||
*/
|
||||
export function withSelectedEntityFeature<Entity>() {
|
||||
return signalStoreFeature(
|
||||
{ state: type<EntityState<Entity>>() },
|
||||
withState<SelectedEntityState>({ selectedEntityId: null }),
|
||||
withComputed(({ entityMap, selectedEntityId }) => ({
|
||||
selectedEntity: computed(() => {
|
||||
const selectedId = selectedEntityId();
|
||||
return selectedId ? entityMap()[selectedId] : null;
|
||||
}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -32,6 +32,8 @@
|
||||
"@microsoft/signalr": "8.0.7",
|
||||
"@microsoft/signalr-protocol-msgpack": "8.0.7",
|
||||
"@ng-select/ng-select": "14.9.0",
|
||||
"@ngrx/operators": "19.2.1",
|
||||
"@ngrx/signals": "19.2.1",
|
||||
"argon2": "0.41.1",
|
||||
"argon2-browser": "1.18.0",
|
||||
"big-integer": "1.6.52",
|
||||
@@ -7854,6 +7856,36 @@
|
||||
"@angular/forms": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrx/operators": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ngrx/operators/-/operators-19.2.1.tgz",
|
||||
"integrity": "sha512-umjSny5nWe7+a3XPeyMfE8vjhXD4ec6nA/KSV7bQA43Yt3eW8cQQr5ng7UZOkC0rbqcBGpSsJPt5thTeXiMXQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngrx/signals": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.2.1.tgz",
|
||||
"integrity": "sha512-Tajd2TVjkxxyFMhnMSWLa5pAWfynjP0VM0B/BCMaLiBrwBBxybxRVENoUDU5tGyiKSax/2tBJC3+sOglmxm27A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "^19.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rxjs": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@ngtools/webpack": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.14.tgz",
|
||||
|
||||
@@ -168,6 +168,8 @@
|
||||
"@microsoft/signalr": "8.0.7",
|
||||
"@microsoft/signalr-protocol-msgpack": "8.0.7",
|
||||
"@ng-select/ng-select": "14.9.0",
|
||||
"@ngrx/operators": "19.2.1",
|
||||
"@ngrx/signals": "19.2.1",
|
||||
"argon2": "0.41.1",
|
||||
"argon2-browser": "1.18.0",
|
||||
"big-integer": "1.6.52",
|
||||
|
||||
Reference in New Issue
Block a user