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/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/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/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 141c5f94cbd..497c10dbcad 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;
};
@@ -111,6 +111,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 f7be814168b..00f8da6f797 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
@@ -256,7 +256,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);
@@ -340,7 +343,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);
@@ -465,10 +471,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 }),
);
}
@@ -529,12 +538,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 }),
);
}
@@ -878,11 +893,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 862fa6f6c4f..22d8e24562d 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
@@ -56,6 +56,9 @@ export class AllActivitiesService {
}
setCriticalAppsReportSummary(summary: OrganizationReportSummary) {
+ if (!summary) {
+ return;
+ }
this.reportSummarySubject$.next({
...this.reportSummarySubject$.getValue(),
totalCriticalApplicationCount: summary.totalApplicationCount,
@@ -66,6 +69,10 @@ export class AllActivitiesService {
}
setAllAppsReportSummary(summary: OrganizationReportSummary) {
+ if (!summary) {
+ return;
+ }
+
this.reportSummarySubject$.next({
...this.reportSummarySubject$.getValue(),
totalMemberCount: summary.totalMemberCount,
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";
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
);
});