mirror of
https://github.com/bitwarden/browser
synced 2026-02-03 02:03:53 +00:00
Merge branch 'main' into dirt/pm-27619/assign-tasks-dialog
This commit is contained in:
@@ -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];
|
||||
1
apps/browser/src/auth/popup/constants/index.ts
Normal file
1
apps/browser/src/auth/popup/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-extension-route.constant";
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: `<ng-content></ng-content>`,
|
||||
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<string | undefined>(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: `<ng-content></ng-content>`,
|
||||
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<boolean | 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: "app-vault-icon",
|
||||
template: `<ng-content></ng-content>`,
|
||||
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<CipherView | undefined>(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<Organization[]>([
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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!]),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
35
apps/web/src/app/auth/constants/auth-web-route.constant.ts
Normal file
35
apps/web/src/app/auth/constants/auth-web-route.constant.ts
Normal file
@@ -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];
|
||||
1
apps/web/src/app/auth/constants/index.ts
Normal file
1
apps/web/src/app/auth/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-web-route.constant";
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ReportState> => {
|
||||
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<ReportState>({ loading: true, error: null, data: null }),
|
||||
startWith<ReportState>({ 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$),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
21
libs/angular/src/auth/constants/auth-route.constant.ts
Normal file
@@ -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];
|
||||
1
libs/angular/src/auth/constants/index.ts
Normal file
1
libs/angular/src/auth/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-route.constant";
|
||||
@@ -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",
|
||||
|
||||
@@ -45,6 +45,8 @@ export class AtRiskPasswordCalloutService {
|
||||
return (
|
||||
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||
associatedCipher &&
|
||||
associatedCipher.edit &&
|
||||
associatedCipher.viewPassword &&
|
||||
!associatedCipher.isDeleted
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user