1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +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:
Alex
2025-11-03 10:25:19 -05:00
committed by GitHub
parent 5912292680
commit c6d759650b
16 changed files with 831 additions and 303 deletions

View File

@@ -56,6 +56,7 @@ import {
OrganizationReportSummary,
ReportStatus,
ReportState,
ApplicationHealthReportDetail,
} from "../../models/report-models";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
@@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService {
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
// New applications that haven't been reviewed (reviewedDate === null)
newApplications$: Observable<string[]> = this.rawReportData$.pipe(
newApplications$: Observable<ApplicationHealthReportDetail[]> = this.rawReportData$.pipe(
map((reportState) => {
if (!reportState.data?.applicationData) {
return [];
}
return reportState.data.applicationData
.filter((app) => app.reviewedDate === null)
.map((app) => app.applicationName);
const reportApplications = reportState.data?.applicationData || [];
const newApplications =
reportState?.data?.reportData.filter((reportApp) =>
reportApplications.some(
(app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null,
),
) || [];
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,
);
}),
distinctUntilChanged(
(prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
@@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService {
}
// 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 updatedApplicationData = this._mergeApplicationData(
const updatedApplicationData = this._updateApplicationData(
existingApplicationData,
newCriticalAppNamesSet,
);
@@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService {
}
/**
* Saves review status for new applications and optionally marks selected ones as critical.
* This method:
* 1. Sets reviewedDate to current date for all applications where reviewedDate === null
* 2. Sets isCritical = true for applications in the selectedCriticalApps array
* Saves review status for new applications and optionally marks
* selected ones as critical
*
* @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
*/
saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable<ReportState> {
this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", {
criticalAppsCount: selectedCriticalApps.length,
});
saveApplicationReviewStatus$(
reviewedApplications: OrganizationReportApplication[],
): Observable<ReportState> {
this.logService.info(
`[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`,
);
return this.rawReportData$.pipe(
take(1),
@@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService {
this._userId$.pipe(filter((userId) => !!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 updatedApplicationData = this._updateReviewStatusAndCriticalFlags(
const updatedApplicationData = this._updateApplicationData(
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 = {
...reportState,
data: {
...reportState.data,
summaryData: updatedSummaryData,
applicationData: updatedApplicationData,
},
} as ReportState;
@@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService {
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(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
@@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService {
organizationDetails,
updatedState,
encryptedData,
metrics,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`,
);
@@ -521,26 +563,44 @@ export class RiskInsightsOrchestratorService {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
// Update applications data with critical marking
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
)
.pipe(
map(() => updatedState),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save review status",
error,
);
return of({ ...reportState, error: "Failed to save application review status" });
}),
);
},
);
// 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),
tap((finalState) => {
this._flagForUpdatesSubject.next({
...finalState,
});
}),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save review status",
error,
);
return of({ ...reportState, error: "Failed to save application review status" });
}),
);
}),
);
}
@@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService {
// Updates the existing application data to include critical applications
// Does not remove critical applications not in the set
private _mergeApplicationData(
private _updateApplicationData(
existingApplications: OrganizationReportApplication[],
criticalApplications: Set<string>,
updatedApplications: (Partial<OrganizationReportApplication> & { applicationName: string })[],
): OrganizationReportApplication[] {
const setToMerge = new Set(criticalApplications);
const arrayToMerge = [...updatedApplications];
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) {
setToMerge.delete(app.applicationName);
let foundApp: Partial<OrganizationReportApplication> | null = null;
// Remove the updated app from the list
if (foundUpdatedIndex >= 0) {
foundApp = arrayToMerge[foundUpdatedIndex];
arrayToMerge.splice(foundUpdatedIndex, 1);
}
return {
...app,
isCritical: foundCritical || app.isCritical,
applicationName: app.applicationName,
isCritical: foundApp?.isCritical || app.isCritical,
reviewedDate: foundApp?.reviewedDate || app.reviewedDate,
};
});
setToMerge.forEach((applicationName) => {
updatedApps.push({
applicationName,
isCritical: true,
const newElements: OrganizationReportApplication[] = arrayToMerge.map(
(newApp): OrganizationReportApplication => ({
applicationName: newApp.applicationName,
isCritical: newApp.isCritical ?? false,
reviewedDate: null,
});
});
}),
);
return updatedApps;
}
/**
* 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;
});
return updatedApps.concat(newElements);
}
// Toggles the isCritical flag on applications via criticalApplicationName

View File

@@ -10,6 +10,8 @@ import {
DrawerType,
RiskInsightsEnrichedData,
ReportStatus,
ApplicationHealthReportDetail,
OrganizationReportApplication,
} from "../../models";
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
@@ -38,7 +40,7 @@ export class RiskInsightsDataService {
readonly hasCiphers$: Observable<boolean | null> = of(null);
// New applications that need review (reviewedDate === null)
readonly newApplications$: Observable<string[]> = of([]);
readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]);
// ------------------------- Drawer Variables ---------------------
// Drawer variables unified into a single BehaviorSubject
@@ -257,7 +259,7 @@ export class RiskInsightsDataService {
return this.orchestrator.removeCriticalApplication$(hostname);
}
saveApplicationReviewStatus(selectedCriticalApps: string[]) {
saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) {
return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps);
}
}

View File

@@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging";
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
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 { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
@NgModule({
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule],
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
providers: [
safeProvider(DefaultAdminTaskService),
safeProvider({
provide: MemberCipherDetailsApiService,
useClass: MemberCipherDetailsApiService,

View File

@@ -1,10 +1,11 @@
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, lastValueFrom } from "rxjs";
import {
AllActivitiesService,
ApplicationHealthReportDetail,
ReportStatus,
RiskInsightsDataService,
} 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 { getUserId } from "@bitwarden/common/auth/services/account.service";
import { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
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 { 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
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -40,7 +42,7 @@ export class AllActivityComponent implements OnInit {
totalCriticalAppsCount = 0;
totalCriticalAppsAtRiskCount = 0;
newApplicationsCount = 0;
newApplications: string[] = [];
newApplications: ApplicationHealthReportDetail[] = [];
passwordChangeMetricHasProgressBar = false;
allAppsHaveReviewDate = false;
isAllCaughtUp = false;
@@ -127,27 +129,38 @@ export class AllActivityComponent implements OnInit {
* Handles the review new applications button click.
* 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, {
newApplications: this.newApplications,
organizationId: organizationId as OrganizationId,
});
await firstValueFrom(dialogRef.closed);
};
await lastValueFrom(dialogRef.closed);
}
/**
* Handles the "View at-risk members" link click.
* Opens the at-risk members drawer for critical applications only.
*/
onViewAtRiskMembers = async () => {
async onViewAtRiskMembers() {
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
};
}
/**
* Handles the "View at-risk applications" link click.
* Opens the at-risk applications drawer for critical applications only.
*/
onViewAtRiskApplications = async () => {
async onViewAtRiskApplications() {
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
};
}
}

View File

@@ -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>

View File

@@ -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>();
}

View File

@@ -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>

View File

@@ -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);
};
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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"),
});
}
};
}

View File

@@ -91,6 +91,7 @@ export class AllApplicationsComponent implements OnInit {
markAppsAsCritical = async () => {
this.markingAsCritical = true;
const count = this.selectedUrls.size;
this.dataService
.saveCriticalApplications(Array.from(this.selectedUrls))
@@ -100,7 +101,7 @@ export class AllApplicationsComponent implements OnInit {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()),
});
this.selectedUrls.clear();
this.markingAsCritical = false;