mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-27619] assign tasks component (#17125)
* feat(dirt): add newApplications$ observable to orchestrator Add reactive observable that filters applicationData for unreviewed apps (reviewedDate === null). Observable automatically updates when report state changes through the pipeline. - Add newApplications$ observable with distinctUntilChanged - Filters rawReportData$.data.applicationData - Uses shareReplay for multi-subscriber efficiency Related to PM-27284 * feat(dirt): add saveApplicationReviewStatus$ to orchestrator Implement method to save application review status and critical flags. Updates all applications where reviewedDate === null to set current date, and marks selected applications as critical. - Add saveApplicationReviewStatus$() method - Add _updateReviewStatusAndCriticalFlags() helper - Uses existing encryption and API update patterns - Single API call for both review status and critical flags - Follows same pattern as saveCriticalApplications$() Related to PM-27284 * feat(dirt): expose newApplications$ in data service Expose orchestrator's newApplications$ observable and save method through RiskInsightsDataService facade. Maintains clean separation between orchestrator (business logic) and components (UI). - Expose newApplications$ observable - Expose saveApplicationReviewStatus() delegation method - Maintains facade pattern consistency Related to PM-27284 * feat(dirt): make AllActivitiesService reactive to new applications Update AllActivitiesService to subscribe to orchestrator's newApplications$ observable instead of receiving data through summary updates. - Subscribe to dataService.newApplications$ in constructor - Add setNewApplications() helper method - Remove newApplications update from setAllAppsReportSummary() - New applications now update reactively when review status changes Related to PM-27284 * feat(dirt): connect dialog to review status save method Update NewApplicationsDialogComponent to call the data service's saveApplicationReviewStatus method when marking applications as critical. - Inject RiskInsightsDataService - Replace placeholder onMarkAsCritical() with real implementation - Handle success/error cases with appropriate toast notifications - Close dialog on successful save - Show different messages based on whether apps were marked critical Related to PM-27284 * feat(dirt): add i18n strings for application review Add internationalization strings for the new applications review dialog success and error messages. - applicationReviewSaved: Success toast title - applicationsMarkedAsCritical: Success message when apps marked critical - newApplicationsReviewed: Success message when apps reviewed only - errorSavingReviewStatus: Error toast title - pleaseTryAgain: Error toast message Related to PM-27284 * fix(dirt): add subscription cleanup to AllActivitiesService Critical fix for production code quality and memory leak prevention. Adds takeUntil pattern to all subscriptions to comply with ADR-0003 (Observable Data Services) requirements. **Subscription Cleanup (ADR-0003 Compliance):** - Add takeUntil pattern to AllActivitiesService subscriptions - Add _destroy$ Subject and destroy() method - Prevents memory leaks by properly unsubscribing from observables - Follows Observable Data Services ADR requirements Changes: - Import Subject and takeUntil from rxjs - Add private _destroy$ Subject for cleanup coordination - Apply takeUntil(this._destroy$) to all 3 subscriptions: - enrichedReportData$ subscription - criticalReportResults$ subscription - newApplications$ subscription - Add destroy() method for proper resource cleanup This ensures proper resource cleanup and follows Bitwarden's architectural decision records for observable management. Related to PM-27284 * fix(dirt): replace manual takeUntil with takeUntilDestroyed in AllActivitiesService Fixes critical memory leak by replacing manual subscription cleanup with Angular's automatic DestroyRef-based cleanup pattern. **Changes:** - Replace `takeUntil(this._destroy$)` with `takeUntilDestroyed()` for all 3 subscriptions - Remove unused `_destroy$` Subject and manual `destroy()` method - Update imports to use `@angular/core/rxjs-interop` **Why:** - Manual `destroy()` method was never called anywhere in codebase - Subscriptions accumulated without cleanup, causing memory leaks - `takeUntilDestroyed()` uses Angular's DestroyRef for automatic cleanup - Aligns with ADR-0003 and .claude/CLAUDE.md requirements **Impact:** - Automatic subscription cleanup when service context is destroyed - Prevents memory leaks during hot module reloads and route changes - Reduces code complexity (no manual lifecycle management needed) Related to PM-27284 * refactor(dirt): remove newApplications from OrganizationReportSummary Removes redundant newApplications field from summary type and uses derived newApplications$ observable from orchestrator instead. **Changes:** - Remove newApplications from OrganizationReportSummary type definition - Remove dummy data array from RiskInsightsReportService.getApplicationsSummary() - Remove newApplications subscription from AllActivitiesService - Update AllActivityComponent to subscribe directly to dataService.newApplications$ **Why:** - Eliminates data redundancy (stored vs derived) - newApplications$ already computes from applicationData.reviewedDate === null - Single source of truth: applicationData is the source - Simplifies encrypted payload (less data in summary) - Better separation: stored data (counts) vs computed data (lists) **Impact:** - No functional changes - UI continues to display new applications correctly - Cleaner architecture with computed observable pattern * cleanup * fix(dirt): improve dialog type safety and error logging Addresses critical PR review issues in NewApplicationsDialogComponent: **Type Safety:** - Replace unsafe type casting `(this as any).dialogRef` with proper DialogRef injection - Inject DialogRef<boolean | undefined> using Angular's inject() function - Ensures type safety and prevents runtime errors from missing dialogRef **Error Handling:** - Add LogService to dialog component - Log errors with "[NewApplicationsDialog]" for debugging - Maintain user-facing error toast while adding server-side logging **Impact:** - Eliminates TypeScript safety bypasses - Improves production debugging capabilities - Follows Angular dependency injection best practices * fixing mock data and test cases for new apps * feat(dirt): create assign tasks view component Create standalone view component for task assignment UI that can be embedded within dialogs or other containers. - Add AssignTasksViewComponent with signal-based inputs/outputs - Use input.required<number>() for selectedApplicationsCount - Use output<void>() for tasksAssigned and back events - Implement task calculation using SecurityTasksApiService - Add onAssignTasks() method with loading state and error handling - Include task summary card UI matching password-change-metric style - Add proper subscription cleanup with takeUntilDestroyed (ADR-0003) - Buttons included in component template (not dialog footer) - Component retrieves organizationId from route params Related to PM-27619 * refactor(dirt): add multi-view state management to new applications dialog Add view state const object and properties to support toggling between application selection and embedded assign tasks component. - Add DialogView const object with SelectApplications and AssignTasks states (ADR-0025) - Add DialogView type for type safety - Add currentView property to track active view - Import AssignTasksViewComponent for embedded use - Add isCalculatingTasks loading state - Inject AllActivitiesService and SecurityTasksApiService for task checking - Implement OnInit with organizationId retrieval from route params - Add proper subscription cleanup with takeUntilDestroyed (ADR-0003) - Expose DialogView constants to template Related to PM-27619 * feat(dirt): integrate assign tasks view into dialog Implement logic to embed AssignTasksViewComponent within dialog and handle communication via event bindings. - Update onMarkAsCritical to check for tasks before closing dialog - Add checkForTasksToAssign() method using SecurityTasksApiService - Conditionally transition to AssignTasks view when tasks are available - Add onTasksAssigned() handler to close dialog after successful assignment - Add onBack() handler to navigate back to SelectApplications view - Add loading state guard to prevent double-click on Mark as Critical button - Only show success toast and close dialog if no tasks to assign Related to PM-27619 * feat(dirt): add embedded assign tasks view to dialog template Update dialog template to conditionally render embedded AssignTasksViewComponent using @if directive. - Add conditional rendering for SelectApplications and AssignTasks views - Update dialog title dynamically based on currentView - Embed dirt-assign-tasks-view component in AssignTasks view - Pass selectedApplicationsCount via input binding - Listen to tasksAssigned and back output events - Show footer buttons only for SelectApplications view - Add loading and disabled states to Mark as Critical button - Change Cancel button to not auto-close (user must navigate) Related to PM-27619 * feat(dirt): add i18n keys for assign tasks view Add localized strings for embedded assign tasks view component. * resolve organizationId and DI issues in assign tasks flow - Pass organizationId via dialog data to prevent async race conditions - Pass organizationId as input to AssignTasksViewComponent (embedded components can't access route params) - Add DefaultAdminTaskService to component providers to fix NullInjectorError - Remove unnecessary route subscription from embedded component - Follow password-change-metric.component.ts pattern for consistency - Add detailed comments explaining architectural decisions and bug fixes * cleanup styling * refactor(dirt): remove newApplications validation from OrganizationReportSummary type guard Removes redundant newApplications field validation from the OrganizationReportSummary type guard and related test cases. **Changes:** - Remove "newApplications" from allowed keys in isOrganizationReportSummary() - Remove newApplications array validation logic - Remove newApplications validation from validateOrganizationReportSummary() - Remove 2 test cases for newApplications validation - Remove newApplications field from 8 test data objects **Rationale:** The newApplications field was removed from OrganizationReportSummary type definition because it's derived data that can be calculated from applicationData (filtering where reviewedDate === null). The data is now accessed via the reactive newApplications$ observable instead of being stored redundantly in the summary object. **Impact:** - No functional changes - UI continues to display new applications via observable - Type guard now correctly validates the actual OrganizationReportSummary structure - Eliminates data redundancy and maintains single source of truth - All 43 tests passing * improve assign tasks view display - Remove illustration/preview section (mailbox icon and prompt text) - Show unique member count instead of calculated task count - Use reportSummary.totalCriticalAtRiskMemberCount from AllActivitiesService - Remove unused SecurityTasksApiService dependency - Follow same pattern as all-activity.component.ts for consistency * logic to fetch totals and new styling * Fix review applications review view and assign view flow * Fix null type checks * refactor assign tasks dialog: use callout component, add video, fix OnPush, improve error handling * Add columns, description, search, and bulk select to new applications dialog * Add count placeholder for critical applications marked message * Address claude comments --------- Co-authored-by: Tom <ttalty@bitwarden.com> Co-authored-by: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Co-authored-by: maxkpower <mpower@bitwarden.com>
This commit is contained in:
@@ -26,6 +26,9 @@
|
|||||||
"reviewAtRiskPasswords": {
|
"reviewAtRiskPasswords": {
|
||||||
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
|
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
|
||||||
},
|
},
|
||||||
|
"reviewAtRiskLoginsPrompt": {
|
||||||
|
"message": "Review at-risk logins"
|
||||||
|
},
|
||||||
"dataLastUpdated": {
|
"dataLastUpdated": {
|
||||||
"message": "Data last updated: $DATE$",
|
"message": "Data last updated: $DATE$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -127,6 +130,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"criticalApplicationsMarked": {
|
||||||
|
"message": "critical applications marked"
|
||||||
|
},
|
||||||
"countOfCriticalApplications": {
|
"countOfCriticalApplications": {
|
||||||
"message": "$COUNT$ critical applications",
|
"message": "$COUNT$ critical applications",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -235,6 +241,15 @@
|
|||||||
"applicationsMarkedAsCriticalSuccess": {
|
"applicationsMarkedAsCriticalSuccess": {
|
||||||
"message": "Applications marked as critical"
|
"message": "Applications marked as critical"
|
||||||
},
|
},
|
||||||
|
"criticalApplicationsMarkedSuccess": {
|
||||||
|
"message": "$COUNT$ applications marked as critical",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"applicationsMarkedAsCriticalFail": {
|
"applicationsMarkedAsCriticalFail": {
|
||||||
"message": "Failed to mark applications as critical"
|
"message": "Failed to mark applications as critical"
|
||||||
},
|
},
|
||||||
@@ -259,6 +274,12 @@
|
|||||||
"membersWithAccessToAtRiskItemsForCriticalApps": {
|
"membersWithAccessToAtRiskItemsForCriticalApps": {
|
||||||
"message": "Members with access to at-risk items for critical applications"
|
"message": "Members with access to at-risk items for critical applications"
|
||||||
},
|
},
|
||||||
|
"membersWithAtRiskPasswords": {
|
||||||
|
"message": "Members with at-risk passwords"
|
||||||
|
},
|
||||||
|
"membersWillReceiveNotification": {
|
||||||
|
"message": "Members will receive a notification to resolve at-risk logins through the browser extension."
|
||||||
|
},
|
||||||
"membersAtRiskCount": {
|
"membersAtRiskCount": {
|
||||||
"message": "$COUNT$ members at-risk",
|
"message": "$COUNT$ members at-risk",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -352,8 +373,11 @@
|
|||||||
"prioritizeCriticalApplications": {
|
"prioritizeCriticalApplications": {
|
||||||
"message": "Prioritize critical applications"
|
"message": "Prioritize critical applications"
|
||||||
},
|
},
|
||||||
"atRiskItems": {
|
"selectCriticalApplicationsDescription": {
|
||||||
"message": "At-risk items"
|
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
|
||||||
|
},
|
||||||
|
"clickIconToMarkAppAsCritical": {
|
||||||
|
"message": "Click the star icon to mark an app as critical"
|
||||||
},
|
},
|
||||||
"markAsCriticalPlaceholder": {
|
"markAsCriticalPlaceholder": {
|
||||||
"message": "Mark as critical functionality will be implemented in a future update"
|
"message": "Mark as critical functionality will be implemented in a future update"
|
||||||
@@ -361,15 +385,6 @@
|
|||||||
"applicationReviewSaved": {
|
"applicationReviewSaved": {
|
||||||
"message": "Application review saved"
|
"message": "Application review saved"
|
||||||
},
|
},
|
||||||
"applicationsMarkedAsCritical": {
|
|
||||||
"message": "$COUNT$ applications marked as critical",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"content": "$1",
|
|
||||||
"example": "3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"newApplicationsReviewed": {
|
"newApplicationsReviewed": {
|
||||||
"message": "New applications reviewed"
|
"message": "New applications reviewed"
|
||||||
},
|
},
|
||||||
@@ -841,6 +856,9 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"message": "Favorites"
|
"message": "Favorites"
|
||||||
},
|
},
|
||||||
|
"taskSummary": {
|
||||||
|
"message": "Task summary"
|
||||||
|
},
|
||||||
"types": {
|
"types": {
|
||||||
"message": "Types"
|
"message": "Types"
|
||||||
},
|
},
|
||||||
@@ -9787,6 +9805,9 @@
|
|||||||
"assignTasks": {
|
"assignTasks": {
|
||||||
"message": "Assign tasks"
|
"message": "Assign tasks"
|
||||||
},
|
},
|
||||||
|
"assignTasksToMembers": {
|
||||||
|
"message": "Assign tasks to members for guided resolution"
|
||||||
|
},
|
||||||
"assignToCollections": {
|
"assignToCollections": {
|
||||||
"message": "Assign to collections"
|
"message": "Assign to collections"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4
Normal file
BIN
apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4
Normal file
Binary file not shown.
BIN
apps/web/src/videos/access-intelligence-assign-tasks.mp4
Normal file
BIN
apps/web/src/videos/access-intelligence-assign-tasks.mp4
Normal file
Binary file not shown.
@@ -56,6 +56,7 @@ import {
|
|||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
ReportStatus,
|
ReportStatus,
|
||||||
ReportState,
|
ReportState,
|
||||||
|
ApplicationHealthReportDetail,
|
||||||
} from "../../models/report-models";
|
} from "../../models/report-models";
|
||||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||||
@@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService {
|
|||||||
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
|
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
|
||||||
|
|
||||||
// New applications that haven't been reviewed (reviewedDate === null)
|
// New applications that haven't been reviewed (reviewedDate === null)
|
||||||
newApplications$: Observable<string[]> = this.rawReportData$.pipe(
|
newApplications$: Observable<ApplicationHealthReportDetail[]> = this.rawReportData$.pipe(
|
||||||
map((reportState) => {
|
map((reportState) => {
|
||||||
if (!reportState.data?.applicationData) {
|
const reportApplications = reportState.data?.applicationData || [];
|
||||||
return [];
|
|
||||||
}
|
const newApplications =
|
||||||
return reportState.data.applicationData
|
reportState?.data?.reportData.filter((reportApp) =>
|
||||||
.filter((app) => app.reviewedDate === null)
|
reportApplications.some(
|
||||||
.map((app) => app.applicationName);
|
(app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null,
|
||||||
}),
|
|
||||||
distinctUntilChanged(
|
|
||||||
(prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]),
|
|
||||||
),
|
),
|
||||||
|
) || [];
|
||||||
|
return newApplications;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged((prev, curr) => {
|
||||||
|
if (prev.length !== curr.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return prev.every(
|
||||||
|
(app, i) =>
|
||||||
|
app.applicationName === curr[i].applicationName &&
|
||||||
|
app.atRiskPasswordCount === curr[i].atRiskPasswordCount,
|
||||||
|
);
|
||||||
|
}),
|
||||||
shareReplay({ bufferSize: 1, refCount: true }),
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a set for quick lookup of the new critical apps
|
// Create a set for quick lookup of the new critical apps
|
||||||
const newCriticalAppNamesSet = new Set(criticalApplications);
|
const newCriticalAppNamesSet = criticalApplications.map((ca) => ({
|
||||||
|
applicationName: ca,
|
||||||
|
isCritical: true,
|
||||||
|
}));
|
||||||
const existingApplicationData = report!.applicationData || [];
|
const existingApplicationData = report!.applicationData || [];
|
||||||
const updatedApplicationData = this._mergeApplicationData(
|
const updatedApplicationData = this._updateApplicationData(
|
||||||
existingApplicationData,
|
existingApplicationData,
|
||||||
newCriticalAppNamesSet,
|
newCriticalAppNamesSet,
|
||||||
);
|
);
|
||||||
@@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves review status for new applications and optionally marks selected ones as critical.
|
* Saves review status for new applications and optionally marks
|
||||||
* This method:
|
* selected ones as critical
|
||||||
* 1. Sets reviewedDate to current date for all applications where reviewedDate === null
|
|
||||||
* 2. Sets isCritical = true for applications in the selectedCriticalApps array
|
|
||||||
*
|
*
|
||||||
* @param selectedCriticalApps Array of application names to mark as critical (can be empty)
|
* @param reviewedApplications Array of application names to mark as reviewed
|
||||||
* @returns Observable of updated ReportState
|
* @returns Observable of updated ReportState
|
||||||
*/
|
*/
|
||||||
saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable<ReportState> {
|
saveApplicationReviewStatus$(
|
||||||
this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", {
|
reviewedApplications: OrganizationReportApplication[],
|
||||||
criticalAppsCount: selectedCriticalApps.length,
|
): Observable<ReportState> {
|
||||||
});
|
this.logService.info(
|
||||||
|
`[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`,
|
||||||
|
);
|
||||||
|
|
||||||
return this.rawReportData$.pipe(
|
return this.rawReportData$.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
@@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._userId$.pipe(filter((userId) => !!userId)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
),
|
),
|
||||||
map(([reportState, organizationDetails, userId]) => {
|
map(([reportState, organizationDetails, userId]) => {
|
||||||
|
const report = reportState?.data;
|
||||||
|
if (!report) {
|
||||||
|
throwError(() => Error("Tried save reviewed applications without a report"));
|
||||||
|
}
|
||||||
|
|
||||||
const existingApplicationData = reportState?.data?.applicationData || [];
|
const existingApplicationData = reportState?.data?.applicationData || [];
|
||||||
const updatedApplicationData = this._updateReviewStatusAndCriticalFlags(
|
const updatedApplicationData = this._updateApplicationData(
|
||||||
existingApplicationData,
|
existingApplicationData,
|
||||||
selectedCriticalApps,
|
reviewedApplications,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Updated summary data after changing critical apps
|
||||||
|
const updatedSummaryData = this.reportService.getApplicationsSummary(
|
||||||
|
report!.reportData,
|
||||||
|
updatedApplicationData,
|
||||||
|
);
|
||||||
|
// Used for creating metrics with updated application data
|
||||||
|
const manualEnrichedApplications = report!.reportData.map(
|
||||||
|
(application): ApplicationHealthReportDetailEnriched => ({
|
||||||
|
...application,
|
||||||
|
isMarkedAsCritical: this.reportService.isCriticalApplication(
|
||||||
|
application,
|
||||||
|
updatedApplicationData,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// For now, merge the report with the critical marking flag to make the enriched type
|
||||||
|
// We don't care about the individual ciphers in this instance
|
||||||
|
// After the report and enriched report types are consolidated, this mapping can be removed
|
||||||
|
// and the class will expose getCriticalApplications
|
||||||
|
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData);
|
||||||
|
|
||||||
const updatedState = {
|
const updatedState = {
|
||||||
...reportState,
|
...reportState,
|
||||||
data: {
|
data: {
|
||||||
...reportState.data,
|
...reportState.data,
|
||||||
|
summaryData: updatedSummaryData,
|
||||||
applicationData: updatedApplicationData,
|
applicationData: updatedApplicationData,
|
||||||
},
|
},
|
||||||
} as ReportState;
|
} as ReportState;
|
||||||
@@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService {
|
|||||||
criticalApps: updatedApplicationData.filter((app) => app.isCritical).length,
|
criticalApps: updatedApplicationData.filter((app) => app.isCritical).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { reportState, organizationDetails, updatedState, userId };
|
return { reportState, organizationDetails, updatedState, userId, metrics };
|
||||||
}),
|
}),
|
||||||
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
|
switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => {
|
||||||
return from(
|
return from(
|
||||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||||
{
|
{
|
||||||
@@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService {
|
|||||||
organizationDetails,
|
organizationDetails,
|
||||||
updatedState,
|
updatedState,
|
||||||
encryptedData,
|
encryptedData,
|
||||||
|
metrics,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
|
||||||
this.logService.debug(
|
this.logService.debug(
|
||||||
`[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`,
|
`[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`,
|
||||||
);
|
);
|
||||||
@@ -521,8 +563,8 @@ export class RiskInsightsOrchestratorService {
|
|||||||
return of({ ...reportState });
|
return of({ ...reportState });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.reportApiService
|
// Update applications data with critical marking
|
||||||
.updateRiskInsightsApplicationData$(
|
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
|
||||||
reportState.data.id,
|
reportState.data.id,
|
||||||
organizationDetails.organizationId,
|
organizationDetails.organizationId,
|
||||||
{
|
{
|
||||||
@@ -530,9 +572,27 @@ export class RiskInsightsOrchestratorService {
|
|||||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.pipe(
|
|
||||||
|
// Update summary after recomputing
|
||||||
|
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
|
||||||
|
reportState.data.id,
|
||||||
|
organizationDetails.organizationId,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
summaryData: encryptedData.encryptedSummaryData.toSdk(),
|
||||||
|
metrics: metrics.toRiskInsightsMetricsData(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
||||||
map(() => updatedState),
|
map(() => updatedState),
|
||||||
|
tap((finalState) => {
|
||||||
|
this._flagForUpdatesSubject.next({
|
||||||
|
...finalState,
|
||||||
|
});
|
||||||
|
}),
|
||||||
catchError((error: unknown) => {
|
catchError((error: unknown) => {
|
||||||
this.logService.error(
|
this.logService.error(
|
||||||
"[RiskInsightsOrchestratorService] Failed to save review status",
|
"[RiskInsightsOrchestratorService] Failed to save review status",
|
||||||
@@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService {
|
|||||||
|
|
||||||
// Updates the existing application data to include critical applications
|
// Updates the existing application data to include critical applications
|
||||||
// Does not remove critical applications not in the set
|
// Does not remove critical applications not in the set
|
||||||
private _mergeApplicationData(
|
private _updateApplicationData(
|
||||||
existingApplications: OrganizationReportApplication[],
|
existingApplications: OrganizationReportApplication[],
|
||||||
criticalApplications: Set<string>,
|
updatedApplications: (Partial<OrganizationReportApplication> & { applicationName: string })[],
|
||||||
): OrganizationReportApplication[] {
|
): OrganizationReportApplication[] {
|
||||||
const setToMerge = new Set(criticalApplications);
|
const arrayToMerge = [...updatedApplications];
|
||||||
|
|
||||||
const updatedApps = existingApplications.map((app) => {
|
const updatedApps = existingApplications.map((app) => {
|
||||||
const foundCritical = setToMerge.has(app.applicationName);
|
// Check if there is an updated app
|
||||||
|
const foundUpdatedIndex = arrayToMerge.findIndex(
|
||||||
|
(ua) => ua.applicationName == app.applicationName,
|
||||||
|
);
|
||||||
|
|
||||||
if (foundCritical) {
|
let foundApp: Partial<OrganizationReportApplication> | null = null;
|
||||||
setToMerge.delete(app.applicationName);
|
// Remove the updated app from the list
|
||||||
|
if (foundUpdatedIndex >= 0) {
|
||||||
|
foundApp = arrayToMerge[foundUpdatedIndex];
|
||||||
|
arrayToMerge.splice(foundUpdatedIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...app,
|
applicationName: app.applicationName,
|
||||||
isCritical: foundCritical || app.isCritical,
|
isCritical: foundApp?.isCritical || app.isCritical,
|
||||||
|
reviewedDate: foundApp?.reviewedDate || app.reviewedDate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setToMerge.forEach((applicationName) => {
|
const newElements: OrganizationReportApplication[] = arrayToMerge.map(
|
||||||
updatedApps.push({
|
(newApp): OrganizationReportApplication => ({
|
||||||
applicationName,
|
applicationName: newApp.applicationName,
|
||||||
isCritical: true,
|
isCritical: newApp.isCritical ?? false,
|
||||||
reviewedDate: null,
|
reviewedDate: null,
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
return updatedApps;
|
return updatedApps.concat(newElements);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates review status and critical flags for applications.
|
|
||||||
* Sets reviewedDate for all apps with null reviewedDate.
|
|
||||||
* Sets isCritical flag for apps in the criticalApplications array.
|
|
||||||
*
|
|
||||||
* @param existingApplications Current application data
|
|
||||||
* @param criticalApplications Array of application names to mark as critical
|
|
||||||
* @returns Updated application data with review dates and critical flags
|
|
||||||
*/
|
|
||||||
private _updateReviewStatusAndCriticalFlags(
|
|
||||||
existingApplications: OrganizationReportApplication[],
|
|
||||||
criticalApplications: string[],
|
|
||||||
): OrganizationReportApplication[] {
|
|
||||||
const criticalSet = new Set(criticalApplications);
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
return existingApplications.map((app) => {
|
|
||||||
const shouldMarkCritical = criticalSet.has(app.applicationName);
|
|
||||||
const needsReviewDate = app.reviewedDate === null;
|
|
||||||
|
|
||||||
// Only create new object if changes are needed
|
|
||||||
if (needsReviewDate || shouldMarkCritical) {
|
|
||||||
return {
|
|
||||||
...app,
|
|
||||||
reviewedDate: needsReviewDate ? currentDate : app.reviewedDate,
|
|
||||||
isCritical: shouldMarkCritical || app.isCritical,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggles the isCritical flag on applications via criticalApplicationName
|
// Toggles the isCritical flag on applications via criticalApplicationName
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
DrawerType,
|
DrawerType,
|
||||||
RiskInsightsEnrichedData,
|
RiskInsightsEnrichedData,
|
||||||
ReportStatus,
|
ReportStatus,
|
||||||
|
ApplicationHealthReportDetail,
|
||||||
|
OrganizationReportApplication,
|
||||||
} from "../../models";
|
} from "../../models";
|
||||||
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ export class RiskInsightsDataService {
|
|||||||
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
||||||
|
|
||||||
// New applications that need review (reviewedDate === null)
|
// New applications that need review (reviewedDate === null)
|
||||||
readonly newApplications$: Observable<string[]> = of([]);
|
readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]);
|
||||||
|
|
||||||
// ------------------------- Drawer Variables ---------------------
|
// ------------------------- Drawer Variables ---------------------
|
||||||
// Drawer variables unified into a single BehaviorSubject
|
// Drawer variables unified into a single BehaviorSubject
|
||||||
@@ -257,7 +259,7 @@ export class RiskInsightsDataService {
|
|||||||
return this.orchestrator.removeCriticalApplication$(hostname);
|
return this.orchestrator.removeCriticalApplication$(hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveApplicationReviewStatus(selectedCriticalApps: string[]) {
|
saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) {
|
||||||
return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps);
|
return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging";
|
|||||||
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
||||||
|
|
||||||
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
|
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
|
||||||
|
import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component";
|
||||||
import { RiskInsightsComponent } from "./risk-insights.component";
|
import { RiskInsightsComponent } from "./risk-insights.component";
|
||||||
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
|
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule],
|
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
|
||||||
providers: [
|
providers: [
|
||||||
|
safeProvider(DefaultAdminTaskService),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: MemberCipherDetailsApiService,
|
provide: MemberCipherDetailsApiService,
|
||||||
useClass: MemberCipherDetailsApiService,
|
useClass: MemberCipherDetailsApiService,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
|
ApplicationHealthReportDetail,
|
||||||
ReportStatus,
|
ReportStatus,
|
||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
@@ -13,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
|||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { getById } from "@bitwarden/common/platform/misc";
|
import { getById } from "@bitwarden/common/platform/misc";
|
||||||
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co
|
|||||||
|
|
||||||
import { ActivityCardComponent } from "./activity-card.component";
|
import { ActivityCardComponent } from "./activity-card.component";
|
||||||
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
||||||
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
|
import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||||
@@ -40,7 +42,7 @@ export class AllActivityComponent implements OnInit {
|
|||||||
totalCriticalAppsCount = 0;
|
totalCriticalAppsCount = 0;
|
||||||
totalCriticalAppsAtRiskCount = 0;
|
totalCriticalAppsAtRiskCount = 0;
|
||||||
newApplicationsCount = 0;
|
newApplicationsCount = 0;
|
||||||
newApplications: string[] = [];
|
newApplications: ApplicationHealthReportDetail[] = [];
|
||||||
passwordChangeMetricHasProgressBar = false;
|
passwordChangeMetricHasProgressBar = false;
|
||||||
allAppsHaveReviewDate = false;
|
allAppsHaveReviewDate = false;
|
||||||
isAllCaughtUp = false;
|
isAllCaughtUp = false;
|
||||||
@@ -127,27 +129,38 @@ export class AllActivityComponent implements OnInit {
|
|||||||
* Handles the review new applications button click.
|
* Handles the review new applications button click.
|
||||||
* Opens a dialog showing the list of new applications that can be marked as critical.
|
* Opens a dialog showing the list of new applications that can be marked as critical.
|
||||||
*/
|
*/
|
||||||
onReviewNewApplications = async () => {
|
async onReviewNewApplications() {
|
||||||
|
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass organizationId via dialog data instead of having the dialog retrieve it from route.
|
||||||
|
// This ensures organizationId is immediately available when dialog opens, preventing
|
||||||
|
// timing issues where the dialog's checkForTasksToAssign() method runs before
|
||||||
|
// organizationId is populated via async route subscription.
|
||||||
const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, {
|
const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, {
|
||||||
newApplications: this.newApplications,
|
newApplications: this.newApplications,
|
||||||
|
organizationId: organizationId as OrganizationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await firstValueFrom(dialogRef.closed);
|
await lastValueFrom(dialogRef.closed);
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the "View at-risk members" link click.
|
* Handles the "View at-risk members" link click.
|
||||||
* Opens the at-risk members drawer for critical applications only.
|
* Opens the at-risk members drawer for critical applications only.
|
||||||
*/
|
*/
|
||||||
onViewAtRiskMembers = async () => {
|
async onViewAtRiskMembers() {
|
||||||
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
|
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the "View at-risk applications" link click.
|
* Handles the "View at-risk applications" link click.
|
||||||
* Opens the at-risk applications drawer for critical applications only.
|
* Opens the at-risk applications drawer for critical applications only.
|
||||||
*/
|
*/
|
||||||
onViewAtRiskApplications = async () => {
|
async onViewAtRiskApplications() {
|
||||||
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
|
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||||
|
<!-- Two-column layout: Left panel (stats) and Right panel (browser extension mockup) -->
|
||||||
|
<div class="tw-flex tw-flex-col md:tw-flex-row tw-gap-6">
|
||||||
|
<!-- Left Panel -->
|
||||||
|
<div class="tw-flex tw-flex-col tw-gap-4 tw-flex-1">
|
||||||
|
<!-- Task Summary Info Card -->
|
||||||
|
<bit-callout type="info" [title]="'taskSummary' | i18n" class="tw-mb-6">
|
||||||
|
<strong>{{ atRiskCriticalMembersCount() }}</strong>
|
||||||
|
{{ "membersWithAtRiskPasswords" | i18n }}
|
||||||
|
for
|
||||||
|
<strong>{{ criticalApplicationsCount() }}</strong>
|
||||||
|
{{ "criticalApplications" | i18n }}
|
||||||
|
</bit-callout>
|
||||||
|
|
||||||
|
<!-- Stat Box: Members with At-Risk Passwords -->
|
||||||
|
<div class="tw-flex tw-items-start tw-gap-3">
|
||||||
|
<bit-icon-tile
|
||||||
|
icon="bwi-users"
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
aria-hidden="true"
|
||||||
|
></bit-icon-tile>
|
||||||
|
<div class="tw-flex tw-flex-col">
|
||||||
|
<span bitTypography="h2" class="tw-font-bold tw-mb-1">
|
||||||
|
{{ atRiskCriticalMembersCount() }}
|
||||||
|
</span>
|
||||||
|
<span bitTypography="body2" class="tw-text-muted">
|
||||||
|
{{ "membersWithAtRiskPasswords" | i18n }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat Box: Critical Applications At-Risk -->
|
||||||
|
<div class="tw-flex tw-items-start tw-gap-3">
|
||||||
|
<bit-icon-tile
|
||||||
|
icon="bwi-desktop"
|
||||||
|
variant="warning"
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
aria-hidden="true"
|
||||||
|
></bit-icon-tile>
|
||||||
|
<div class="tw-flex tw-flex-col">
|
||||||
|
<div class="tw-flex tw-items-baseline tw-gap-2 tw-mb-1">
|
||||||
|
<span bitTypography="h2" class="tw-font-bold tw-text-main">
|
||||||
|
{{ criticalApplicationsCount() }}
|
||||||
|
</span>
|
||||||
|
<span bitTypography="body1" class="tw-text-muted">
|
||||||
|
of {{ totalApplicationsCount() }} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span bitTypography="body2" class="tw-text-muted">
|
||||||
|
{{ "criticalApplications" | i18n }} at-risk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel: Browser Extension Video -->
|
||||||
|
<div class="tw-flex tw-flex-col tw-gap-4 tw-flex-1">
|
||||||
|
<video
|
||||||
|
class="tw-w-full tw-rounded-lg"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
src="/videos/access-intelligence-assign-tasks.mp4"
|
||||||
|
appDarkImgSrc="/videos/access-intelligence-assign-tasks-dark.mp4"
|
||||||
|
aria-hidden="true"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Description Text -->
|
||||||
|
<div bitTypography="helper" class="tw-text-muted">
|
||||||
|
{{ "membersWillReceiveNotification" | i18n }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ButtonModule,
|
||||||
|
CalloutComponent,
|
||||||
|
IconTileComponent,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
import { DarkImageSourceDirective } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
|
||||||
|
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded component for displaying task assignment UI.
|
||||||
|
* Not a dialog - intended to be embedded within a parent dialog.
|
||||||
|
*
|
||||||
|
* Important: This component provides its own instances of AccessIntelligenceSecurityTasksService
|
||||||
|
* and DefaultAdminTaskService. These services are scoped to this component to ensure proper
|
||||||
|
* dependency injection when the component is dynamically rendered within the structure.
|
||||||
|
* Without these providers, Angular would throw NullInjectorError when trying to inject
|
||||||
|
* DefaultAdminTaskService, which is required by AccessIntelligenceSecurityTasksService.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: "dirt-assign-tasks-view",
|
||||||
|
templateUrl: "./assign-tasks-view.component.html",
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ButtonModule,
|
||||||
|
TypographyModule,
|
||||||
|
I18nPipe,
|
||||||
|
IconTileComponent,
|
||||||
|
DarkImageSourceDirective,
|
||||||
|
CalloutComponent,
|
||||||
|
],
|
||||||
|
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
|
||||||
|
})
|
||||||
|
export class AssignTasksViewComponent {
|
||||||
|
readonly criticalApplicationsCount = input.required<number>();
|
||||||
|
readonly totalApplicationsCount = input.required<number>();
|
||||||
|
readonly atRiskCriticalMembersCount = input.required<number>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<bit-dialog [dialogSize]="'large'">
|
||||||
|
<span bitDialogTitle>
|
||||||
|
{{
|
||||||
|
currentView() === DialogView.SelectApplications
|
||||||
|
? ("prioritizeCriticalApplications" | i18n)
|
||||||
|
: ("assignTasksToMembers" | i18n)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div bitDialogContent>
|
||||||
|
@if (currentView() === DialogView.SelectApplications) {
|
||||||
|
<div>
|
||||||
|
<p bitTypography="body1" class="tw-mb-5">
|
||||||
|
{{ "selectCriticalApplicationsDescription" | i18n }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-2.5 tw-mb-5">
|
||||||
|
<i class="bwi bwi-star-f tw-text-xl" aria-hidden="true"></i>
|
||||||
|
<p bitTypography="helper" class="tw-text-muted tw-mb-0">
|
||||||
|
{{ "clickIconToMarkAppAsCritical" | i18n }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dirt-review-applications-view
|
||||||
|
[applications]="getApplications()"
|
||||||
|
[selectedApplications]="selectedApplications()"
|
||||||
|
(onToggleSelection)="toggleSelection($event)"
|
||||||
|
(onToggleAll)="toggleAll()"
|
||||||
|
></dirt-review-applications-view>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (currentView() === DialogView.AssignTasks) {
|
||||||
|
<dirt-assign-tasks-view
|
||||||
|
[criticalApplicationsCount]="selectedApplications().size"
|
||||||
|
[totalApplicationsCount]="this.dialogParams.newApplications.length"
|
||||||
|
[atRiskCriticalMembersCount]="atRiskCriticalMembersCount()"
|
||||||
|
>
|
||||||
|
</dirt-assign-tasks-view>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (currentView() === DialogView.SelectApplications) {
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
size="small"
|
||||||
|
buttonType="primary"
|
||||||
|
(click)="handleMarkAsCritical()"
|
||||||
|
[disabled]="markingAsCritical()"
|
||||||
|
[loading]="markingAsCritical()"
|
||||||
|
[attr.aria-label]="'markAsCritical' | i18n"
|
||||||
|
>
|
||||||
|
{{ "markAsCritical" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
size="small"
|
||||||
|
buttonType="secondary"
|
||||||
|
[bitDialogClose]="false"
|
||||||
|
[attr.aria-label]="'cancel' | i18n"
|
||||||
|
>
|
||||||
|
{{ "cancel" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
@if (currentView() == DialogView.AssignTasks) {
|
||||||
|
<!-- Footer: Action Buttons -->
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
size="small"
|
||||||
|
buttonType="primary"
|
||||||
|
(click)="handleAssignTasks()"
|
||||||
|
[disabled]="saving()"
|
||||||
|
[loading]="saving()"
|
||||||
|
[attr.aria-label]="'assignTasks' | i18n"
|
||||||
|
>
|
||||||
|
{{ "assignTasks" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
size="small"
|
||||||
|
buttonType="secondary"
|
||||||
|
(click)="onBack()"
|
||||||
|
[disabled]="saving()"
|
||||||
|
[attr.aria-label]="'back' | i18n"
|
||||||
|
>
|
||||||
|
{{ "back" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
</bit-dialog>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
Inject,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { from, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApplicationHealthReportDetail,
|
||||||
|
ApplicationHealthReportDetailEnriched,
|
||||||
|
OrganizationReportApplication,
|
||||||
|
RiskInsightsDataService,
|
||||||
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
|
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
import {
|
||||||
|
ButtonModule,
|
||||||
|
DIALOG_DATA,
|
||||||
|
DialogModule,
|
||||||
|
DialogRef,
|
||||||
|
DialogService,
|
||||||
|
ToastService,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
|
||||||
|
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
||||||
|
|
||||||
|
import { AssignTasksViewComponent } from "./assign-tasks-view.component";
|
||||||
|
import { ReviewApplicationsViewComponent } from "./review-applications-view.component";
|
||||||
|
|
||||||
|
export interface NewApplicationsDialogData {
|
||||||
|
newApplications: ApplicationHealthReportDetail[];
|
||||||
|
/**
|
||||||
|
* Organization ID is passed via dialog data instead of being retrieved from route params.
|
||||||
|
* This ensures organizationId is available immediately when the dialog opens,
|
||||||
|
* preventing async timing issues where user clicks "Mark as critical" before
|
||||||
|
* the route subscription has fired.
|
||||||
|
*/
|
||||||
|
organizationId: OrganizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View states for dialog navigation
|
||||||
|
* Using const object pattern per ADR-0025 (Deprecate TypeScript Enums)
|
||||||
|
*/
|
||||||
|
export const DialogView = Object.freeze({
|
||||||
|
SelectApplications: "select",
|
||||||
|
AssignTasks: "assign",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export type DialogView = (typeof DialogView)[keyof typeof DialogView];
|
||||||
|
|
||||||
|
// Possible results for closing the dialog
|
||||||
|
export const NewApplicationsDialogResultType = Object.freeze({
|
||||||
|
Close: "close",
|
||||||
|
Complete: "complete",
|
||||||
|
} as const);
|
||||||
|
export type NewApplicationsDialogResultType =
|
||||||
|
(typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "dirt-new-applications-dialog",
|
||||||
|
templateUrl: "./new-applications-dialog.component.html",
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogModule,
|
||||||
|
TypographyModule,
|
||||||
|
I18nPipe,
|
||||||
|
AssignTasksViewComponent,
|
||||||
|
ReviewApplicationsViewComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NewApplicationsDialogComponent {
|
||||||
|
destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
// View state management
|
||||||
|
protected readonly currentView = signal<DialogView>(DialogView.SelectApplications);
|
||||||
|
// Expose DialogView constants to template
|
||||||
|
protected readonly DialogView = DialogView;
|
||||||
|
|
||||||
|
// Review new applications view
|
||||||
|
// Applications selected to save as critical applications
|
||||||
|
protected readonly selectedApplications = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Assign tasks variables
|
||||||
|
readonly criticalApplicationsCount = signal<number>(0);
|
||||||
|
readonly totalApplicationsCount = signal<number>(0);
|
||||||
|
readonly atRiskCriticalMembersCount = signal<number>(0);
|
||||||
|
readonly saving = signal<boolean>(false);
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
protected readonly markingAsCritical = signal<boolean>(false);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData,
|
||||||
|
private dialogRef: DialogRef<NewApplicationsDialogResultType>,
|
||||||
|
private dataService: RiskInsightsDataService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
||||||
|
private logService: LogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the new applications dialog
|
||||||
|
* @param dialogService The dialog service instance
|
||||||
|
* @param data Dialog data containing the list of new applications and organizationId
|
||||||
|
* @returns Dialog reference
|
||||||
|
*/
|
||||||
|
static open(dialogService: DialogService, data: NewApplicationsDialogData) {
|
||||||
|
return dialogService.open<boolean | undefined, NewApplicationsDialogData>(
|
||||||
|
NewApplicationsDialogComponent,
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApplications() {
|
||||||
|
return this.dialogParams.newApplications;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the selection state of an application.
|
||||||
|
* @param applicationName The application to toggle
|
||||||
|
*/
|
||||||
|
toggleSelection(applicationName: string) {
|
||||||
|
this.selectedApplications.update((current) => {
|
||||||
|
const temp = new Set(current);
|
||||||
|
if (temp.has(applicationName)) {
|
||||||
|
temp.delete(applicationName);
|
||||||
|
} else {
|
||||||
|
temp.add(applicationName);
|
||||||
|
}
|
||||||
|
return temp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the selection state of all applications.
|
||||||
|
* If all are selected, unselect all. Otherwise, select all.
|
||||||
|
*/
|
||||||
|
toggleAll() {
|
||||||
|
const allApplicationNames = this.dialogParams.newApplications.map((app) => app.applicationName);
|
||||||
|
const allSelected = this.selectedApplications().size === allApplicationNames.length;
|
||||||
|
|
||||||
|
this.selectedApplications.update(() => {
|
||||||
|
return allSelected ? new Set() : new Set(allApplicationNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMarkAsCritical() {
|
||||||
|
if (this.markingAsCritical() || this.saving()) {
|
||||||
|
return; // Prevent action if already processing
|
||||||
|
}
|
||||||
|
this.markingAsCritical.set(true);
|
||||||
|
|
||||||
|
const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) =>
|
||||||
|
this.selectedApplications().has(newApp.applicationName),
|
||||||
|
);
|
||||||
|
|
||||||
|
const atRiskCriticalMembersCount = getUniqueMembers(
|
||||||
|
onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails),
|
||||||
|
).length;
|
||||||
|
this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount);
|
||||||
|
|
||||||
|
this.currentView.set(DialogView.AssignTasks);
|
||||||
|
this.markingAsCritical.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the assign tasks button click
|
||||||
|
*/
|
||||||
|
protected handleAssignTasks() {
|
||||||
|
if (this.saving()) {
|
||||||
|
return; // Prevent double-click
|
||||||
|
}
|
||||||
|
this.saving.set(true);
|
||||||
|
|
||||||
|
// Create updated organization report application types with new review date
|
||||||
|
// and critical marking based on selected applications
|
||||||
|
const newReviewDate = new Date();
|
||||||
|
const updatedApplications: OrganizationReportApplication[] =
|
||||||
|
this.dialogParams.newApplications.map((app) => ({
|
||||||
|
applicationName: app.applicationName,
|
||||||
|
isCritical: this.selectedApplications().has(app.applicationName),
|
||||||
|
reviewedDate: newReviewDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Save the application review dates and critical markings
|
||||||
|
this.dataService
|
||||||
|
.saveApplicationReviewStatus(updatedApplications)
|
||||||
|
.pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
switchMap((updatedState) => {
|
||||||
|
// After initial save is complete, created the assigned tasks
|
||||||
|
// for at risk passwords
|
||||||
|
const updatedStateApplicationData = updatedState?.data?.applicationData || [];
|
||||||
|
// Manual enrich for type matching
|
||||||
|
// TODO Consolidate in model updates
|
||||||
|
const manualEnrichedApplications =
|
||||||
|
updatedState?.data?.reportData.map(
|
||||||
|
(application): ApplicationHealthReportDetailEnriched => ({
|
||||||
|
...application,
|
||||||
|
isMarkedAsCritical: updatedStateApplicationData.some(
|
||||||
|
(a) => a.applicationName == application.applicationName && a.isCritical,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
) || [];
|
||||||
|
return from(
|
||||||
|
this.accessIntelligenceSecurityTasksService.assignTasks(
|
||||||
|
this.dialogParams.organizationId,
|
||||||
|
manualEnrichedApplications,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: this.i18nService.t("applicationReviewSaved"),
|
||||||
|
message: this.i18nService.t("newApplicationsReviewed"),
|
||||||
|
});
|
||||||
|
this.saving.set(false);
|
||||||
|
this.handleAssigningCompleted();
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.logService.error(
|
||||||
|
"[NewApplicationsDialog] Failed to save application review or assign tasks",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorSavingReviewStatus"),
|
||||||
|
message: this.i18nService.t("pleaseTryAgain"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the dialog when the "Cancel" button is selected
|
||||||
|
*/
|
||||||
|
handleCancel() {
|
||||||
|
this.dialogRef.close(NewApplicationsDialogResultType.Close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the tasksAssigned event from the embedded component.
|
||||||
|
* Closes the dialog with success indicator.
|
||||||
|
*/
|
||||||
|
protected handleAssigningCompleted = () => {
|
||||||
|
// Tasks were successfully assigned - close dialog
|
||||||
|
this.dialogRef.close(NewApplicationsDialogResultType.Complete);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the back event from the embedded component.
|
||||||
|
* Returns to the select applications view.
|
||||||
|
*/
|
||||||
|
protected onBack = () => {
|
||||||
|
this.currentView.set(DialogView.SelectApplications);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<div class="tw-space-y-3">
|
||||||
|
<bit-search
|
||||||
|
[placeholder]="'searchApps' | i18n"
|
||||||
|
[(ngModel)]="searchText"
|
||||||
|
(ngModelChange)="onSearchTextChanged($event)"
|
||||||
|
></bit-search>
|
||||||
|
|
||||||
|
<div class="tw-overflow-x-auto">
|
||||||
|
<table class="tw-w-full tw-border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="tw-border-b tw-border-secondary-300">
|
||||||
|
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-w-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
|
||||||
|
(click)="toggleAll()"
|
||||||
|
[attr.aria-label]="isAllSelected() ? ('unselectAll' | i18n) : ('selectAll' | i18n)"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="bwi tw-text-muted"
|
||||||
|
[ngClass]="isAllSelected() ? 'bwi-star-f' : 'bwi-star'"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-font-semibold">
|
||||||
|
{{ "application" | i18n }}
|
||||||
|
</th>
|
||||||
|
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
|
||||||
|
{{ "atRiskPasswords" | i18n }}
|
||||||
|
</th>
|
||||||
|
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
|
||||||
|
{{ "totalPasswords" | i18n }}
|
||||||
|
</th>
|
||||||
|
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
|
||||||
|
{{ "atRiskMembers" | i18n }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (app of filteredApplications(); track app.applicationName) {
|
||||||
|
<tr class="tw-border-b tw-border-secondary-300 hover:tw-bg-background-alt">
|
||||||
|
<td class="tw-py-3 tw-px-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
|
||||||
|
(click)="toggleSelection(app.applicationName)"
|
||||||
|
[attr.aria-label]="
|
||||||
|
selectedApplications().has(app.applicationName)
|
||||||
|
? ('unselectApplication' | i18n)
|
||||||
|
: ('selectApplication' | i18n)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="bwi tw-text-muted"
|
||||||
|
[ngClass]="
|
||||||
|
selectedApplications().has(app.applicationName) ? 'bwi-star-f' : 'bwi-star'
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td bitTypography="body1" class="tw-py-3 tw-px-2">
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<i class="bwi bwi-globe tw-text-muted" aria-hidden="true"></i>
|
||||||
|
<span>{{ app.applicationName }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
|
||||||
|
{{ app.atRiskPasswordCount }}
|
||||||
|
</td>
|
||||||
|
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
|
||||||
|
{{ app.passwordCount }}
|
||||||
|
</td>
|
||||||
|
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
|
||||||
|
{{ app.atRiskMemberCount }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, input, output, ChangeDetectionStrategy, signal, computed } from "@angular/core";
|
||||||
|
import { FormsModule } from "@angular/forms";
|
||||||
|
|
||||||
|
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
|
import { ButtonModule, DialogModule, SearchModule, TypographyModule } from "@bitwarden/components";
|
||||||
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: "dirt-review-applications-view",
|
||||||
|
templateUrl: "./review-applications-view.component.html",
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogModule,
|
||||||
|
FormsModule,
|
||||||
|
SearchModule,
|
||||||
|
TypographyModule,
|
||||||
|
I18nPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ReviewApplicationsViewComponent {
|
||||||
|
readonly applications = input.required<ApplicationHealthReportDetail[]>();
|
||||||
|
readonly selectedApplications = input.required<Set<string>>();
|
||||||
|
|
||||||
|
protected readonly searchText = signal<string>("");
|
||||||
|
|
||||||
|
// Filter applications based on search text
|
||||||
|
protected readonly filteredApplications = computed(() => {
|
||||||
|
const search = this.searchText().toLowerCase();
|
||||||
|
if (!search) {
|
||||||
|
return this.applications();
|
||||||
|
}
|
||||||
|
return this.applications().filter((app) => app.applicationName.toLowerCase().includes(search));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the selected applications from the view
|
||||||
|
onToggleSelection = output<string>();
|
||||||
|
onToggleAll = output<void>();
|
||||||
|
|
||||||
|
toggleSelection(applicationName: string): void {
|
||||||
|
this.onToggleSelection.emit(applicationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(): void {
|
||||||
|
this.onToggleAll.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllSelected(): boolean {
|
||||||
|
const filtered = this.filteredApplications();
|
||||||
|
return (
|
||||||
|
filtered.length > 0 &&
|
||||||
|
filtered.every((app) => this.selectedApplications().has(app.applicationName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchTextChanged(searchText: string): void {
|
||||||
|
this.searchText.set(searchText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<bit-dialog [dialogSize]="'default'">
|
|
||||||
<span bitDialogTitle>{{ "prioritizeCriticalApplications" | i18n }}</span>
|
|
||||||
<div bitDialogContent>
|
|
||||||
<div class="tw-overflow-x-auto">
|
|
||||||
<table class="tw-w-full tw-border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="tw-border-b tw-border-secondary-300">
|
|
||||||
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-w-12"></th>
|
|
||||||
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-font-semibold">
|
|
||||||
{{ "application" | i18n }}
|
|
||||||
</th>
|
|
||||||
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
|
|
||||||
{{ "atRiskItems" | i18n }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (app of newApplications; track app) {
|
|
||||||
<tr class="tw-border-b tw-border-secondary-300 hover:tw-bg-background-alt">
|
|
||||||
<td class="tw-py-3 tw-px-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
|
|
||||||
(click)="toggleSelection(app)"
|
|
||||||
[attr.aria-label]="
|
|
||||||
isSelected(app) ? ('unselectApplication' | i18n) : ('selectApplication' | i18n)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bwi tw-text-muted"
|
|
||||||
[ngClass]="isSelected(app) ? 'bwi-star-f' : 'bwi-star'"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td bitTypography="body1" class="tw-py-3 tw-px-2">
|
|
||||||
<div class="tw-flex tw-items-center tw-gap-2">
|
|
||||||
<i class="bwi bwi-globe tw-text-muted" aria-hidden="true"></i>
|
|
||||||
<span>{{ app }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right tw-text-muted">—</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ng-container bitDialogFooter>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
bitButton
|
|
||||||
size="small"
|
|
||||||
buttonType="primary"
|
|
||||||
(click)="onMarkAsCritical()"
|
|
||||||
[attr.aria-label]="'markAsCritical' | i18n"
|
|
||||||
>
|
|
||||||
{{ "markAsCritical" | i18n }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
bitButton
|
|
||||||
size="small"
|
|
||||||
buttonType="secondary"
|
|
||||||
[bitDialogClose]
|
|
||||||
[attr.aria-label]="'cancel' | i18n"
|
|
||||||
>
|
|
||||||
{{ "cancel" | i18n }}
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
</bit-dialog>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
|
||||||
import { Component, inject } from "@angular/core";
|
|
||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
|
||||||
import {
|
|
||||||
ButtonModule,
|
|
||||||
DialogModule,
|
|
||||||
DialogRef,
|
|
||||||
DialogService,
|
|
||||||
ToastService,
|
|
||||||
TypographyModule,
|
|
||||||
} from "@bitwarden/components";
|
|
||||||
import { I18nPipe } from "@bitwarden/ui-common";
|
|
||||||
|
|
||||||
export interface NewApplicationsDialogData {
|
|
||||||
newApplications: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
|
||||||
@Component({
|
|
||||||
templateUrl: "./new-applications-dialog.component.html",
|
|
||||||
imports: [CommonModule, ButtonModule, DialogModule, TypographyModule, I18nPipe],
|
|
||||||
})
|
|
||||||
export class NewApplicationsDialogComponent {
|
|
||||||
protected newApplications: string[] = [];
|
|
||||||
protected selectedApplications: Set<string> = new Set<string>();
|
|
||||||
|
|
||||||
private dialogRef = inject(DialogRef<boolean | undefined>);
|
|
||||||
private dataService = inject(RiskInsightsDataService);
|
|
||||||
private toastService = inject(ToastService);
|
|
||||||
private i18nService = inject(I18nService);
|
|
||||||
private logService = inject(LogService);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the new applications dialog
|
|
||||||
* @param dialogService The dialog service instance
|
|
||||||
* @param data Dialog data containing the list of new applications
|
|
||||||
* @returns Dialog reference
|
|
||||||
*/
|
|
||||||
static open(dialogService: DialogService, data: NewApplicationsDialogData) {
|
|
||||||
const ref = dialogService.open<boolean | undefined, NewApplicationsDialogData>(
|
|
||||||
NewApplicationsDialogComponent,
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the component's data after opening
|
|
||||||
const instance = ref.componentInstance as NewApplicationsDialogComponent;
|
|
||||||
if (instance) {
|
|
||||||
instance.newApplications = data.newApplications;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection state of an application.
|
|
||||||
* @param applicationName The application to toggle
|
|
||||||
*/
|
|
||||||
toggleSelection = (applicationName: string) => {
|
|
||||||
if (this.selectedApplications.has(applicationName)) {
|
|
||||||
this.selectedApplications.delete(applicationName);
|
|
||||||
} else {
|
|
||||||
this.selectedApplications.add(applicationName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if an application is currently selected.
|
|
||||||
* @param applicationName The application to check
|
|
||||||
* @returns True if selected, false otherwise
|
|
||||||
*/
|
|
||||||
isSelected = (applicationName: string): boolean => {
|
|
||||||
return this.selectedApplications.has(applicationName);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the "Mark as Critical" button click.
|
|
||||||
* Saves review status for all new applications and marks selected ones as critical.
|
|
||||||
* Closes the dialog on success.
|
|
||||||
*/
|
|
||||||
onMarkAsCritical = async () => {
|
|
||||||
const selectedCriticalApps = Array.from(this.selectedApplications);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await firstValueFrom(this.dataService.saveApplicationReviewStatus(selectedCriticalApps));
|
|
||||||
|
|
||||||
this.toastService.showToast({
|
|
||||||
variant: "success",
|
|
||||||
title: this.i18nService.t("applicationReviewSaved"),
|
|
||||||
message:
|
|
||||||
selectedCriticalApps.length > 0
|
|
||||||
? this.i18nService.t("applicationsMarkedAsCritical", selectedCriticalApps.length)
|
|
||||||
: this.i18nService.t("newApplicationsReviewed"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dialog with success indicator
|
|
||||||
this.dialogRef.close(true);
|
|
||||||
} catch {
|
|
||||||
this.logService.error("[NewApplicationsDialog] Failed to save review status");
|
|
||||||
this.toastService.showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: this.i18nService.t("errorSavingReviewStatus"),
|
|
||||||
message: this.i18nService.t("pleaseTryAgain"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -91,6 +91,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
|
|
||||||
markAppsAsCritical = async () => {
|
markAppsAsCritical = async () => {
|
||||||
this.markingAsCritical = true;
|
this.markingAsCritical = true;
|
||||||
|
const count = this.selectedUrls.size;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.saveCriticalApplications(Array.from(this.selectedUrls))
|
.saveCriticalApplications(Array.from(this.selectedUrls))
|
||||||
@@ -100,7 +101,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: "",
|
title: "",
|
||||||
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
|
message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()),
|
||||||
});
|
});
|
||||||
this.selectedUrls.clear();
|
this.selectedUrls.clear();
|
||||||
this.markingAsCritical = false;
|
this.markingAsCritical = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user