From 51a557514f112717dad2cb068cd770d60a284b8b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 29 Oct 2025 14:47:55 -0700 Subject: [PATCH 1/3] [PM-20379] Fix At-risk password task permission bug (#17110) * [PM-20379] Fix at risk password task permission checks * [PM-20379] Fix at risk password component specs * [PM-20379] Cleanup FIXMEs * [PM-20379] Update to OnPush * [PM-20379] Add tests for pendingTasks$ * [PM-20379] Reduce test boilerplate / redundancy * [PM-20379] Cleanup as any * [PM-20379] Remove redundant "should" language --- .../at-risk-passwords.component.spec.ts | 67 +++-- .../at-risk-passwords.component.ts | 14 +- .../at-risk-password-callout.service.spec.ts | 244 +++++++++++++++++- .../at-risk-password-callout.service.ts | 2 + 4 files changed, 298 insertions(+), 29 deletions(-) diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index c58798d9d12..96c597113a5 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; @@ -37,43 +37,32 @@ import { AtRiskCarouselDialogResult } from "../at-risk-carousel-dialog/at-risk-c import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; import { AtRiskPasswordsComponent } from "./at-risk-passwords.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-header", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupHeaderComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() pageTitle: string | undefined; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() backAction: (() => void) | undefined; + readonly pageTitle = input(undefined); + readonly backAction = input<(() => void) | undefined>(undefined); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-page", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupPageComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean | undefined; + readonly loading = input(undefined); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault-icon", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockAppIcon { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() cipher: CipherView | undefined; + readonly cipher = input(undefined); } describe("AtRiskPasswordsComponent", () => { @@ -109,11 +98,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); mockOrgs$ = new BehaviorSubject([ @@ -235,6 +228,38 @@ describe("AtRiskPasswordsComponent", () => { organizationId: "org", name: "Item 1", isDeleted: true, + edit: true, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have edit permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: false, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have viewPassword permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: true, + viewPassword: false, } as CipherView, ]); @@ -288,11 +313,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org2", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 3eeb2d1917b..94fdb00f566 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { + Component, + DestroyRef, + inject, + OnInit, + signal, + ChangeDetectionStrategy, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { @@ -58,8 +65,6 @@ import { import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [ PopupPageComponent, @@ -82,6 +87,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; ], selector: "vault-at-risk-passwords", templateUrl: "./at-risk-passwords.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AtRiskPasswordsComponent implements OnInit { private taskService = inject(TaskService); @@ -158,6 +164,8 @@ export class AtRiskPasswordsComponent implements OnInit { t.type === SecurityTaskType.UpdateAtRiskCredential && t.cipherId != null && ciphers[t.cipherId] != null && + ciphers[t.cipherId].edit && + ciphers[t.cipherId].viewPassword && !ciphers[t.cipherId].isDeleted, ) .map((t) => ciphers[t.cipherId!]), diff --git a/libs/vault/src/services/at-risk-password-callout.service.spec.ts b/libs/vault/src/services/at-risk-password-callout.service.spec.ts index 47b83f4a903..5b687970c4b 100644 --- a/libs/vault/src/services/at-risk-password-callout.service.spec.ts +++ b/libs/vault/src/services/at-risk-password-callout.service.spec.ts @@ -28,6 +28,8 @@ class MockCipherView { constructor( public id: string, private deleted: boolean, + public edit: boolean = true, + public viewPassword: boolean = true, ) {} get isDeleted() { return this.deleted; @@ -65,33 +67,261 @@ describe("AtRiskPasswordCalloutService", () => { service = TestBed.inject(AtRiskPasswordCalloutService); }); + describe("pendingTasks$", () => { + it.each([ + { + description: + "returns tasks filtered by UpdateAtRiskCredential type with valid cipher permissions", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, true), + ], + expectedLength: 2, + expectedFirstId: "t1", + }, + { + description: "filters out tasks with wrong task type", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: 999 as SecurityTaskType, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks with missing associated cipher", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c-nonexistent", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [new MockCipherView("c1", false, true, true)], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher edit permission is false", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, false, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher viewPassword permission is false", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, false), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher is deleted", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", true, true, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + ])("$description", async ({ tasks, ciphers, expectedLength, expectedFirstId }) => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(expectedLength); + if (expectedFirstId) { + expect(result[0].id).toBe(expectedFirstId); + } + }); + + it("correctly filters mixed valid and invalid tasks", async () => { + const tasks: SecurityTask[] = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t3", + cipherId: "c3", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t4", + cipherId: "c4", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t5", + cipherId: "c5", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ]; + const ciphers = [ + new MockCipherView("c1", false, true, true), // valid + new MockCipherView("c2", false, false, true), // no edit + new MockCipherView("c3", true, true, true), // deleted + new MockCipherView("c4", false, true, false), // no viewPassword + // c5 missing + ]; + + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("t1"); + }); + + it.each([ + { + description: "returns empty array when no tasks match filter criteria", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [new MockCipherView("c1", true, true, true)], // deleted + }, + { + description: "returns empty array when no pending tasks exist", + tasks: [], + ciphers: [new MockCipherView("c1", false, true, true)], + }, + ])("$description", async ({ tasks, ciphers }) => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(0); + }); + }); + describe("completedTasks$", () => { - it(" should return true if completed tasks exist", async () => { + it("returns true if completed tasks exist", async () => { const tasks: SecurityTask[] = [ { id: "t1", cipherId: "c1", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, { id: "t2", cipherId: "c2", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Pending, - } as any, + } as SecurityTask, { id: "t3", cipherId: "nope", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, { id: "t4", cipherId: "c3", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, ]; jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks)); @@ -110,7 +340,7 @@ describe("AtRiskPasswordCalloutService", () => { jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([])); }); - it("should return false if banner has been dismissed", async () => { + it("returns false if banner has been dismissed", async () => { const state: AtRiskPasswordCalloutData = { hasInteractedWithTasks: true, tasksBannerDismissed: true, @@ -123,7 +353,7 @@ describe("AtRiskPasswordCalloutService", () => { expect(result).toBe(false); }); - it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => { + it("returns true when has completed tasks, no pending tasks, and banner not dismissed", async () => { const completedTasks = [ { id: "t1", diff --git a/libs/vault/src/services/at-risk-password-callout.service.ts b/libs/vault/src/services/at-risk-password-callout.service.ts index d3af4f8421e..214a061399e 100644 --- a/libs/vault/src/services/at-risk-password-callout.service.ts +++ b/libs/vault/src/services/at-risk-password-callout.service.ts @@ -45,6 +45,8 @@ export class AtRiskPasswordCalloutService { return ( t.type === SecurityTaskType.UpdateAtRiskCredential && associatedCipher && + associatedCipher.edit && + associatedCipher.viewPassword && !associatedCipher.isDeleted ); }); From a1570fc8b1562a9fe1117624a9ea1c078d3b85a2 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:28:21 -0400 Subject: [PATCH 2/3] feat(AuthRouteConstants): [Auth/PM-27370] Convert auth routes to use constants (#16980) * PM-22663 WIP on auth route constants * PM-22663 - Convert desktop & extension to use constants - first pass * PM-22663 - Further clean up * PM-22663 - catch more missed routes * PM-22663 - add barrel files * PM-22663 - Per PR feedback, add missing as const * PM-22663 - Per PR feedback and TS docs, use same name for const enum like and derived type. Adjusted filenames to be singular. * PM-22663 - Per PR feedback update desktop app routing module since auto rename didn't update it for whatever reason. --- .../auth-extension-route.constant.ts | 8 +++ .../browser/src/auth/popup/constants/index.ts | 1 + apps/browser/src/popup/app-routing.module.ts | 38 +++++++------ apps/desktop/src/app/app-routing.module.ts | 29 +++++----- .../auth/constants/auth-web-route.constant.ts | 35 ++++++++++++ apps/web/src/app/auth/constants/index.ts | 1 + apps/web/src/app/oss-routing.module.ts | 56 ++++++++++--------- .../src/auth/constants/auth-route.constant.ts | 21 +++++++ libs/angular/src/auth/constants/index.ts | 1 + 9 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts create mode 100644 apps/browser/src/auth/popup/constants/index.ts create mode 100644 apps/web/src/app/auth/constants/auth-web-route.constant.ts create mode 100644 apps/web/src/app/auth/constants/index.ts create mode 100644 libs/angular/src/auth/constants/auth-route.constant.ts create mode 100644 libs/angular/src/auth/constants/index.ts diff --git a/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts new file mode 100644 index 00000000000..5ea6fac7ebb --- /dev/null +++ b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts @@ -0,0 +1,8 @@ +// Full routes that auth owns in the extension +export const AuthExtensionRoute = Object.freeze({ + AccountSecurity: "account-security", + DeviceManagement: "device-management", + AccountSwitcher: "account-switcher", +} as const); + +export type AuthExtensionRoute = (typeof AuthExtensionRoute)[keyof typeof AuthExtensionRoute]; diff --git a/apps/browser/src/auth/popup/constants/index.ts b/apps/browser/src/auth/popup/constants/index.ts new file mode 100644 index 00000000000..59855040fd3 --- /dev/null +++ b/apps/browser/src/auth/popup/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-extension-route.constant"; diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 02adaff9b83..1834beb391e 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -2,6 +2,7 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { activeAuthGuard, @@ -45,6 +46,7 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; +import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; @@ -148,7 +150,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn(unauthRouteOverrides)], children: [ { @@ -167,7 +169,7 @@ const routes: Routes = [ ], }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: ExtensionAnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -259,13 +261,13 @@ const routes: Routes = [ data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "account-security", + path: AuthExtensionRoute.AccountSecurity, component: AccountSecurityComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "device-management", + path: AuthExtensionRoute.DeviceManagement, component: ExtensionDeviceManagementComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, @@ -341,7 +343,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { elevation: 1, @@ -361,13 +363,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -382,7 +384,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -390,7 +392,7 @@ const routes: Routes = [ } satisfies RouteDataProperties, }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard], data: { pageIcon: VaultIcon, @@ -411,7 +413,7 @@ const routes: Routes = [ ], }, { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -434,7 +436,7 @@ const routes: Routes = [ ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: VaultIcon, @@ -456,7 +458,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -479,7 +481,7 @@ const routes: Routes = [ ], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageTitle: { @@ -502,7 +504,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -519,7 +521,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -557,7 +559,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], children: [ { @@ -576,7 +578,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, data: { elevation: 1, hideFooter: true, @@ -698,7 +700,7 @@ const routes: Routes = [ canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { - path: "account-switcher", + path: AuthExtensionRoute.AccountSwitcher, component: AccountSwitcherComponent, data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties, }, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index a809a1b23a2..b6e86ba19ff 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { authGuard, @@ -65,7 +66,7 @@ const routes: Routes = [ canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, component: AnonLayoutWrapperComponent, children: [ { @@ -81,7 +82,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: AnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -123,7 +124,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -141,13 +142,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -160,7 +161,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [maxAccountsGuardFn()], data: { pageTitle: { @@ -179,7 +180,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -187,7 +188,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginDecryptionOptionsComponent }], }, { - path: "sso", + path: AuthRoute.Sso, data: { pageIcon: VaultIcon, pageTitle: { @@ -207,7 +208,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -227,7 +228,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -240,7 +241,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -278,7 +279,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { @@ -295,7 +296,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -304,7 +305,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { diff --git a/apps/web/src/app/auth/constants/auth-web-route.constant.ts b/apps/web/src/app/auth/constants/auth-web-route.constant.ts new file mode 100644 index 00000000000..c1e714786e9 --- /dev/null +++ b/apps/web/src/app/auth/constants/auth-web-route.constant.ts @@ -0,0 +1,35 @@ +// Web route segments auth owns under shared infrastructure +export const AuthWebRouteSegment = Object.freeze({ + // settings routes + Account: "account", + EmergencyAccess: "emergency-access", + + // settings/security routes + Password: "password", + TwoFactor: "two-factor", + SecurityKeys: "security-keys", + DeviceManagement: "device-management", +} as const); + +export type AuthWebRouteSegment = (typeof AuthWebRouteSegment)[keyof typeof AuthWebRouteSegment]; + +// Full routes that auth owns in the web app +export const AuthWebRoute = Object.freeze({ + SignUpLinkExpired: "signup-link-expired", + RecoverTwoFactor: "recover-2fa", + AcceptEmergencyAccessInvite: "accept-emergency", + RecoverDeleteAccount: "recover-delete", + VerifyRecoverDeleteAccount: "verify-recover-delete", + AcceptOrganizationInvite: "accept-organization", + + // Composed routes from segments (allowing for router.navigate / routerLink usage) + AccountSettings: `settings/${AuthWebRouteSegment.Account}`, + EmergencyAccessSettings: `settings/${AuthWebRouteSegment.EmergencyAccess}`, + + PasswordSettings: `settings/security/${AuthWebRouteSegment.Password}`, + TwoFactorSettings: `settings/security/${AuthWebRouteSegment.TwoFactor}`, + SecurityKeysSettings: `settings/security/${AuthWebRouteSegment.SecurityKeys}`, + DeviceManagement: `settings/security/${AuthWebRouteSegment.DeviceManagement}`, +} as const); + +export type AuthWebRoute = (typeof AuthWebRoute)[keyof typeof AuthWebRoute]; diff --git a/apps/web/src/app/auth/constants/index.ts b/apps/web/src/app/auth/constants/index.ts new file mode 100644 index 00000000000..3d84e3729de --- /dev/null +++ b/apps/web/src/app/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-web-route.constant"; diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 45ed6dc8eb9..319adb1d8c6 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { authGuard, lockGuard, @@ -55,6 +56,7 @@ import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/m import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; +import { AuthWebRoute, AuthWebRouteSegment } from "./auth/constants/auth-web-route.constant"; import { deepLinkGuard } from "./auth/guards/deep-link/deep-link.guard"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; @@ -93,12 +95,12 @@ const routes: Routes = [ // so that the redirectGuard does not interrupt the navigation. { path: "register", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { path: "trial", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { @@ -114,7 +116,7 @@ const routes: Routes = [ }, { path: "verify-email", component: VerifyEmailTokenComponent }, { - path: "accept-organization", + path: AuthWebRoute.AcceptOrganizationInvite, canActivate: [deepLinkGuard()], component: AcceptOrganizationComponent, data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies RouteDataProperties, @@ -128,7 +130,7 @@ const routes: Routes = [ doNotSaveUrl: false, } satisfies RouteDataProperties, }, - { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, + { path: "recover", pathMatch: "full", redirectTo: AuthWebRoute.RecoverTwoFactor }, { path: "verify-recover-delete-org", component: VerifyRecoverDeleteOrgComponent, @@ -142,7 +144,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -164,7 +166,7 @@ const routes: Routes = [ ], }, { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -189,7 +191,7 @@ const routes: Routes = [ ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -203,7 +205,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -229,7 +231,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -250,7 +252,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -264,7 +266,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -286,7 +288,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -315,7 +317,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -324,7 +326,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "signup-link-expired", + path: AuthWebRoute.SignUpLinkExpired, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorTimeoutIcon, @@ -337,13 +339,13 @@ const routes: Routes = [ path: "", component: RegistrationLinkExpiredComponent, data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -368,7 +370,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, component: TwoFactorAuthComponent, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ @@ -408,7 +410,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn()], children: [ { @@ -430,7 +432,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "recover-2fa", + path: AuthWebRoute.RecoverTwoFactor, canActivate: [unauthGuardFn()], children: [ { @@ -452,7 +454,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [ { @@ -471,7 +473,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "accept-emergency", + path: AuthWebRoute.AcceptEmergencyAccessInvite, canActivate: [deepLinkGuard()], data: { pageTitle: { @@ -492,7 +494,7 @@ const routes: Routes = [ ], }, { - path: "recover-delete", + path: AuthWebRoute.RecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -514,7 +516,7 @@ const routes: Routes = [ ], }, { - path: "verify-recover-delete", + path: AuthWebRoute.VerifyRecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -596,7 +598,7 @@ const routes: Routes = [ ], }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { @@ -652,9 +654,9 @@ const routes: Routes = [ { path: "settings", children: [ - { path: "", pathMatch: "full", redirectTo: "account" }, + { path: "", pathMatch: "full", redirectTo: AuthWebRouteSegment.Account }, { - path: "account", + path: AuthWebRouteSegment.Account, component: AccountComponent, data: { titleId: "myAccount" } satisfies RouteDataProperties, }, @@ -680,7 +682,7 @@ const routes: Routes = [ ), }, { - path: "emergency-access", + path: AuthWebRouteSegment.EmergencyAccess, children: [ { path: "", diff --git a/libs/angular/src/auth/constants/auth-route.constant.ts b/libs/angular/src/auth/constants/auth-route.constant.ts new file mode 100644 index 00000000000..caacfbbc4a8 --- /dev/null +++ b/libs/angular/src/auth/constants/auth-route.constant.ts @@ -0,0 +1,21 @@ +/** + * Constants for auth team owned full routes which are shared across clients. + */ +export const AuthRoute = Object.freeze({ + SignUp: "signup", + FinishSignUp: "finish-signup", + Login: "login", + LoginWithDevice: "login-with-device", + AdminApprovalRequested: "admin-approval-requested", + PasswordHint: "hint", + LoginInitiated: "login-initiated", + SetInitialPassword: "set-initial-password", + ChangePassword: "change-password", + Sso: "sso", + TwoFactor: "2fa", + AuthenticationTimeout: "authentication-timeout", + NewDeviceVerification: "device-verification", + LoginWithPasskey: "login-with-passkey", +} as const); + +export type AuthRoute = (typeof AuthRoute)[keyof typeof AuthRoute]; diff --git a/libs/angular/src/auth/constants/index.ts b/libs/angular/src/auth/constants/index.ts new file mode 100644 index 00000000000..d8e362734c1 --- /dev/null +++ b/libs/angular/src/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-route.constant"; From 55a6e25c0d9874226d6f165a5ba1b6b436f94599 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 29 Oct 2025 21:23:00 -0500 Subject: [PATCH 3/3] [PM-27291] Preserve critical apps after run-report is selected (#17114) --- .../risk-insights/models/report-models.ts | 3 +- .../risk-insights-orchestrator.service.ts | 50 +++++++++++++++---- .../domain/risk-insights-type-guards.spec.ts | 10 ++++ .../domain/risk-insights-type-guards.ts | 8 +-- .../services/view/all-activities.service.ts | 7 +++ 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 93955c7dbfb..76892004d3e 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -15,7 +15,7 @@ import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health"; */ export type MemberDetails = { userGuid: string; - userName: string; + userName?: string; email: string; cipherId: string; }; @@ -112,6 +112,7 @@ export interface ReportState { loading: boolean; error: string | null; data: RiskInsightsData | null; + organizationId?: string; } // TODO Make Versioned models for structure changes diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 2435fe12038..61b7c659977 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -240,7 +240,10 @@ export class RiskInsightsOrchestratorService { .pipe( map(() => updatedState), tap((finalState) => { - this._markUnmarkUpdatesSubject.next(finalState); + this._markUnmarkUpdatesSubject.next({ + ...finalState, + organizationId: reportState.organizationId, + }); }), catchError((error: unknown) => { this.logService.error("Failed to save updated applicationData", error); @@ -324,7 +327,10 @@ export class RiskInsightsOrchestratorService { .pipe( map(() => updatedState), tap((finalState) => { - this._markUnmarkUpdatesSubject.next(finalState); + this._markUnmarkUpdatesSubject.next({ + ...finalState, + organizationId: reportState.organizationId, + }); }), catchError((error: unknown) => { this.logService.error("Failed to save updated applicationData", error); @@ -343,10 +349,13 @@ export class RiskInsightsOrchestratorService { loading: false, error: null, data: result ?? null, + organizationId, }; }), - catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })), - startWith({ loading: true, error: null, data: null }), + catchError(() => + of({ loading: false, error: "Failed to fetch report", data: null, organizationId }), + ), + startWith({ loading: true, error: null, data: null, organizationId }), ); } @@ -407,12 +416,18 @@ export class RiskInsightsOrchestratorService { creationDate: new Date(), contentEncryptionKey, }, + organizationId, }; }), catchError((): Observable => { - return of({ loading: false, error: "Failed to generate or save report", data: null }); + return of({ + loading: false, + error: "Failed to generate or save report", + data: null, + organizationId, + }); }), - startWith({ loading: true, error: null, data: null }), + startWith({ loading: true, error: null, data: null, organizationId }), ); } @@ -723,11 +738,24 @@ export class RiskInsightsOrchestratorService { newReportGeneration$, this._markUnmarkUpdates$, ).pipe( - scan((prevState: ReportState, currState: ReportState) => ({ - ...prevState, - ...currState, - data: currState.data, - })), + scan((prevState: ReportState, currState: ReportState) => { + // If organization changed, use new state completely (don't preserve old data) + // This allows null data to clear old org's data when switching orgs + if (currState.organizationId && prevState.organizationId !== currState.organizationId) { + return { + ...currState, + data: currState.data, // Allow null to clear old org's data + }; + } + + // Same org (or no org ID): preserve data when currState.data is null + // This preserves critical flags during loading states within the same org + return { + ...prevState, + ...currState, + data: currState.data !== null ? currState.data : prevState.data, + }; + }), startWith({ loading: false, error: null, data: null }), shareReplay({ bufferSize: 1, refCount: true }), takeUntil(this._destroy$), diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts index 32505088818..6c130dc77fa 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts @@ -379,6 +379,16 @@ describe("Risk Insights Type Guards", () => { expect(isMemberDetails(invalidData)).toBe(false); }); + it("should return true for undefined userName", () => { + const validData = { + userGuid: "user-1", + userName: undefined as string | undefined, + email: "john@example.com", + cipherId: "cipher-1", + }; + expect(isMemberDetails(validData)).toBe(true); + }); + it("should return false for empty email", () => { const invalidData = { userGuid: "user-1", diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts index b1d2550d4fa..e3bcb3e18a2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts @@ -48,9 +48,11 @@ export function isMemberDetails(obj: any): obj is MemberDetails { typeof obj.userGuid === "string" && obj.userGuid.length > 0 && obj.userGuid.length <= MAX_STRING_LENGTH && - typeof obj.userName === "string" && - obj.userName.length > 0 && - obj.userName.length <= MAX_STRING_LENGTH && + (obj.userName === null || + obj.userName === undefined || + (typeof obj.userName === "string" && + obj.userName.length > 0 && + obj.userName.length <= MAX_STRING_LENGTH)) && typeof obj.email === "string" && obj.email.length > 0 && obj.email.length <= MAX_STRING_LENGTH && diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index c275ad8c355..a25f2ba7fe8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -55,6 +55,9 @@ export class AllActivitiesService { } setCriticalAppsReportSummary(summary: OrganizationReportSummary) { + if (!summary) { + return; + } this.reportSummarySubject$.next({ ...this.reportSummarySubject$.getValue(), totalCriticalApplicationCount: summary.totalApplicationCount, @@ -65,6 +68,10 @@ export class AllActivitiesService { } setAllAppsReportSummary(summary: OrganizationReportSummary) { + if (!summary) { + return; + } + this.reportSummarySubject$.next({ ...this.reportSummarySubject$.getValue(), totalMemberCount: summary.totalMemberCount,