mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00:00
[PM-14421] Access Intelligence: Introduce At-risk Passwords Page (#13044)
* [PM-14421] Add initial at risk password page component and route * [PM-14421] Add new at-risk-password guard and update task service to consider feature flag for tasksEnabled$ * [PM-14421] Export vault observable utilities to be used outside of libs/vault * [PM-14421] Implement at risk passwords page * [PM-14421] Add temporary callout for at-risk tasks to browser vault view * [PM-14421] Fix service registration after merge * [PM-14421] Fix organization service usage after merge * [PM-14421] Add autofill setting callout * [PM-14421] Fix failing test * [PM-14421] Change autofill setting check and toggle * [PM-14421] Make autofill setting callout dismissal persistent * [PM-14421] Fix tests * [PM-14421] Fix button structure * [PM-14421] Handle plural tasks i18n * [PM-14421] Fix cipher service usage after refactor on main * [PM-14421] Fix at-risk-password spec file
This commit is contained in:
@@ -2363,6 +2363,70 @@
|
|||||||
"autofillBlockedNoticeGuidance": {
|
"autofillBlockedNoticeGuidance": {
|
||||||
"message": "Change this in settings"
|
"message": "Change this in settings"
|
||||||
},
|
},
|
||||||
|
"change": {
|
||||||
|
"message": "Change"
|
||||||
|
},
|
||||||
|
"changeButtonTitle": {
|
||||||
|
"message": "Change password - $ITEMNAME$",
|
||||||
|
"placeholders": {
|
||||||
|
"itemname": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "Secret Item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"atRiskPasswords": {
|
||||||
|
"message": "At-risk passwords"
|
||||||
|
},
|
||||||
|
"atRiskPasswordsDescSingleOrg": {
|
||||||
|
"message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at risk.",
|
||||||
|
"placeholders": {
|
||||||
|
"organization": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "Acme Corp"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"content": "$2",
|
||||||
|
"example": "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"atRiskPasswordsDescMultiOrg": {
|
||||||
|
"message": "Your organizations are requesting you change the $COUNT$ passwords because they are at risk.",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reviewAndChangeAtRiskPassword": {
|
||||||
|
"message": "Review and change one at-risk password"
|
||||||
|
},
|
||||||
|
"reviewAndChangeAtRiskPasswordsPlural": {
|
||||||
|
"message": "Review and change $COUNT$ at-risk passwords",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"changeAtRiskPasswordsFaster": {
|
||||||
|
"message": "Change at-risk passwords faster"
|
||||||
|
},
|
||||||
|
"changeAtRiskPasswordsFasterDesc": {
|
||||||
|
"message": "Update your settings so you can quickly autofill your passwords and generate new ones"
|
||||||
|
},
|
||||||
|
"turnOnAutofill": {
|
||||||
|
"message": "Turn on autofill"
|
||||||
|
},
|
||||||
|
"turnedOnAutofill": {
|
||||||
|
"message": "Turned on autofill"
|
||||||
|
},
|
||||||
|
"dismiss": {
|
||||||
|
"message": "Dismiss"
|
||||||
|
},
|
||||||
"websiteItemLabel": {
|
"websiteItemLabel": {
|
||||||
"message": "Website $number$ (URI)",
|
"message": "Website $number$ (URI)",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
|
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
|
||||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||||
import {
|
import {
|
||||||
|
activeAuthGuard,
|
||||||
authGuard,
|
authGuard,
|
||||||
lockGuard,
|
lockGuard,
|
||||||
activeAuthGuard,
|
|
||||||
redirectGuard,
|
redirectGuard,
|
||||||
tdeDecryptionRequiredGuard,
|
tdeDecryptionRequiredGuard,
|
||||||
unauthGuardFn,
|
unauthGuardFn,
|
||||||
@@ -23,10 +23,14 @@ import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guard
|
|||||||
import {
|
import {
|
||||||
AnonLayoutWrapperComponent,
|
AnonLayoutWrapperComponent,
|
||||||
AnonLayoutWrapperData,
|
AnonLayoutWrapperData,
|
||||||
LoginComponent,
|
DevicesIcon,
|
||||||
LoginSecondaryContentComponent,
|
DeviceVerificationIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
|
LoginComponent,
|
||||||
|
LoginDecryptionOptionsComponent,
|
||||||
|
LoginSecondaryContentComponent,
|
||||||
LoginViaAuthRequestComponent,
|
LoginViaAuthRequestComponent,
|
||||||
|
NewDeviceVerificationComponent,
|
||||||
PasswordHintComponent,
|
PasswordHintComponent,
|
||||||
RegistrationFinishComponent,
|
RegistrationFinishComponent,
|
||||||
RegistrationLockAltIcon,
|
RegistrationLockAltIcon,
|
||||||
@@ -35,14 +39,10 @@ import {
|
|||||||
RegistrationStartSecondaryComponentData,
|
RegistrationStartSecondaryComponentData,
|
||||||
RegistrationUserAddIcon,
|
RegistrationUserAddIcon,
|
||||||
SetPasswordJitComponent,
|
SetPasswordJitComponent,
|
||||||
UserLockIcon,
|
|
||||||
VaultIcon,
|
|
||||||
LoginDecryptionOptionsComponent,
|
|
||||||
DevicesIcon,
|
|
||||||
SsoComponent,
|
SsoComponent,
|
||||||
TwoFactorTimeoutIcon,
|
TwoFactorTimeoutIcon,
|
||||||
NewDeviceVerificationComponent,
|
UserLockIcon,
|
||||||
DeviceVerificationIcon,
|
VaultIcon,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { LockComponent } from "@bitwarden/key-management-ui";
|
import { LockComponent } from "@bitwarden/key-management-ui";
|
||||||
@@ -90,7 +90,9 @@ import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about-
|
|||||||
import { ExportBrowserV2Component } from "../tools/popup/settings/export/export-browser-v2.component";
|
import { ExportBrowserV2Component } from "../tools/popup/settings/export/export-browser-v2.component";
|
||||||
import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component";
|
import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component";
|
||||||
import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component";
|
import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component";
|
||||||
|
import { canAccessAtRiskPasswords } from "../vault/guards/at-risk-passwords.guard";
|
||||||
import { clearVaultStateGuard } from "../vault/guards/clear-vault-state.guard";
|
import { clearVaultStateGuard } from "../vault/guards/clear-vault-state.guard";
|
||||||
|
import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component";
|
||||||
import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
|
import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
|
||||||
import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component";
|
import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component";
|
||||||
import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component";
|
import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component";
|
||||||
@@ -752,6 +754,11 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "at-risk-passwords",
|
||||||
|
component: AtRiskPasswordsComponent,
|
||||||
|
canActivate: [authGuard, canAccessAtRiskPasswords],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "account-switcher",
|
path: "account-switcher",
|
||||||
component: AccountSwitcherComponent,
|
component: AccountSwitcherComponent,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
|
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { Subject, merge, of } from "rxjs";
|
import { merge, of, Subject } from "rxjs";
|
||||||
|
|
||||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||||
@@ -11,21 +11,21 @@ import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/sa
|
|||||||
import {
|
import {
|
||||||
CLIENT_TYPE,
|
CLIENT_TYPE,
|
||||||
DEFAULT_VAULT_TIMEOUT,
|
DEFAULT_VAULT_TIMEOUT,
|
||||||
|
ENV_ADDITIONAL_REGIONS,
|
||||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||||
MEMORY_STORAGE,
|
MEMORY_STORAGE,
|
||||||
OBSERVABLE_DISK_STORAGE,
|
OBSERVABLE_DISK_STORAGE,
|
||||||
OBSERVABLE_MEMORY_STORAGE,
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
|
SafeInjectionToken,
|
||||||
SECURE_STORAGE,
|
SECURE_STORAGE,
|
||||||
SYSTEM_THEME_OBSERVABLE,
|
SYSTEM_THEME_OBSERVABLE,
|
||||||
SafeInjectionToken,
|
|
||||||
ENV_ADDITIONAL_REGIONS,
|
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import {
|
import {
|
||||||
AnonLayoutWrapperDataService,
|
AnonLayoutWrapperDataService,
|
||||||
LoginComponentService,
|
LoginComponentService,
|
||||||
SsoComponentService,
|
|
||||||
LoginDecryptionOptionsService,
|
LoginDecryptionOptionsService,
|
||||||
|
SsoComponentService,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
@@ -111,10 +111,10 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
|||||||
import { CompactModeService, DialogService, ToastService } from "@bitwarden/components";
|
import { CompactModeService, DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
import {
|
import {
|
||||||
KdfConfigService,
|
|
||||||
KeyService,
|
|
||||||
BiometricsService,
|
BiometricsService,
|
||||||
DefaultKeyService,
|
DefaultKeyService,
|
||||||
|
KdfConfigService,
|
||||||
|
KeyService,
|
||||||
} from "@bitwarden/key-management";
|
} from "@bitwarden/key-management";
|
||||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|||||||
29
apps/browser/src/vault/guards/at-risk-passwords.guard.ts
Normal file
29
apps/browser/src/vault/guards/at-risk-passwords.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { inject } from "@angular/core";
|
||||||
|
import { CanActivateFn } from "@angular/router";
|
||||||
|
import { switchMap, tap } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
import { filterOutNullish, TaskService } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
export const canAccessAtRiskPasswords: CanActivateFn = () => {
|
||||||
|
const accountService = inject(AccountService);
|
||||||
|
const taskService = inject(TaskService);
|
||||||
|
const toastService = inject(ToastService);
|
||||||
|
const i18nService = inject(I18nService);
|
||||||
|
|
||||||
|
return accountService.activeAccount$.pipe(
|
||||||
|
filterOutNullish(),
|
||||||
|
switchMap((user) => taskService.tasksEnabled$(user.id)),
|
||||||
|
tap((tasksEnabled) => {
|
||||||
|
if (!tasksEnabled) {
|
||||||
|
toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
message: i18nService.t("accessDenied"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<bit-callout *ngIf="(pendingTasks$ | async)?.length as taskCount" type="warning" [title]="''">
|
||||||
|
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
|
||||||
|
<a bitLink [routerLink]="'/at-risk-passwords'">
|
||||||
|
{{
|
||||||
|
(taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural")
|
||||||
|
| i18n: taskCount.toString()
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</bit-callout>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, inject } from "@angular/core";
|
||||||
|
import { RouterModule } from "@angular/router";
|
||||||
|
import { map, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
|
||||||
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
import { filterOutNullish, SecurityTaskType, TaskService } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "vault-at-risk-password-callout",
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe],
|
||||||
|
templateUrl: "./at-risk-password-callout.component.html",
|
||||||
|
})
|
||||||
|
export class AtRiskPasswordCalloutComponent {
|
||||||
|
private taskService = inject(TaskService);
|
||||||
|
private activeAccount$ = inject(AccountService).activeAccount$.pipe(filterOutNullish());
|
||||||
|
|
||||||
|
protected pendingTasks$ = this.activeAccount$.pipe(
|
||||||
|
switchMap((user) =>
|
||||||
|
this.taskService
|
||||||
|
.pendingTasks$(user.id)
|
||||||
|
.pipe(
|
||||||
|
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { map, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BANNERS_DISMISSED_DISK,
|
||||||
|
StateProvider,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
export const AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition<boolean>(
|
||||||
|
BANNERS_DISMISSED_DISK,
|
||||||
|
"atRiskPasswordAutofillBannerDismissed",
|
||||||
|
{
|
||||||
|
deserializer: (bannersDismissed) => bannersDismissed,
|
||||||
|
clearOn: [], // Do not clear dismissed banners
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AtRiskPasswordPageService {
|
||||||
|
private stateProvider = inject(StateProvider);
|
||||||
|
|
||||||
|
isCalloutDismissed(userId: UserId): Observable<boolean> {
|
||||||
|
return this.stateProvider
|
||||||
|
.getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
|
||||||
|
.state$.pipe(map((dismissed) => !!dismissed));
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissCallout(userId: UserId): Promise<void> {
|
||||||
|
await this.stateProvider
|
||||||
|
.getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
|
||||||
|
.update(() => true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<popup-page [loading]="loading$ | async">
|
||||||
|
<popup-header slot="header" [pageTitle]="'atRiskPasswords' | i18n" showBackButton>
|
||||||
|
<ng-container slot="end">
|
||||||
|
<app-pop-out></app-pop-out>
|
||||||
|
</ng-container>
|
||||||
|
</popup-header>
|
||||||
|
|
||||||
|
<bit-callout
|
||||||
|
*ngIf="!(inlineAutofillSettingEnabled$ | async) && !(calloutDismissed$ | async)"
|
||||||
|
type="info"
|
||||||
|
[title]="'changeAtRiskPasswordsFaster' | i18n"
|
||||||
|
data-testid="autofill-callout"
|
||||||
|
>
|
||||||
|
<p bitTypography="body2">{{ "changeAtRiskPasswordsFasterDesc" | i18n }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
(click)="activateInlineAutofillMenuVisibility()"
|
||||||
|
data-testid="turn-on-autofill-button"
|
||||||
|
>
|
||||||
|
{{ "turnOnAutofill" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
(click)="dismissCallout()"
|
||||||
|
data-testid="dismiss-callout-button"
|
||||||
|
>
|
||||||
|
{{ "dismiss" | i18n }}
|
||||||
|
</button>
|
||||||
|
</bit-callout>
|
||||||
|
|
||||||
|
<ng-container *ngIf="atRiskItems$ | async as items">
|
||||||
|
<p bitTypography="body2">{{ pageDescription$ | async }}</p>
|
||||||
|
|
||||||
|
<bit-item-group>
|
||||||
|
<bit-item *ngFor="let cipher of items">
|
||||||
|
<button
|
||||||
|
bit-item-content
|
||||||
|
type="button"
|
||||||
|
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
|
||||||
|
(click)="viewCipher(cipher)"
|
||||||
|
>
|
||||||
|
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
|
||||||
|
<app-vault-icon [cipher]="cipher"></app-vault-icon>
|
||||||
|
</div>
|
||||||
|
<span data-testid="item-name">{{ cipher.name }}</span>
|
||||||
|
<i
|
||||||
|
*ngIf="cipher.hasAttachments"
|
||||||
|
class="bwi bwi-paperclip bwi-sm"
|
||||||
|
[appA11yTitle]="'attachments' | i18n"
|
||||||
|
></i>
|
||||||
|
<span slot="secondary">{{ cipher.subTitle }}</span>
|
||||||
|
</button>
|
||||||
|
<bit-item-action slot="end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitBadge
|
||||||
|
variant="primary"
|
||||||
|
(click)="launchChangePassword(cipher)"
|
||||||
|
[title]="'changeButtonTitle' | i18n: cipher.name"
|
||||||
|
[attr.aria-label]="'changeButtonTitle' | i18n: cipher.name"
|
||||||
|
>
|
||||||
|
{{ "change" | i18n }}
|
||||||
|
</button>
|
||||||
|
</bit-item-action>
|
||||||
|
</bit-item>
|
||||||
|
</bit-item-group>
|
||||||
|
</ng-container>
|
||||||
|
</popup-page>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { Component, Input } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { IconComponent } from "@bitwarden/angular/vault/components/icon.component";
|
||||||
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||||
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
PasswordRepromptService,
|
||||||
|
SecurityTask,
|
||||||
|
SecurityTaskType,
|
||||||
|
TaskService,
|
||||||
|
} from "@bitwarden/vault";
|
||||||
|
|
||||||
|
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||||
|
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||||
|
|
||||||
|
import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
||||||
|
import { AtRiskPasswordsComponent } from "./at-risk-passwords.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "popup-header",
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
})
|
||||||
|
class MockPopupHeaderComponent {
|
||||||
|
@Input() pageTitle: string | undefined;
|
||||||
|
@Input() backAction: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "popup-page",
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
})
|
||||||
|
class MockPopupPageComponent {
|
||||||
|
@Input() loading: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "app-vault-icon",
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
})
|
||||||
|
class MockAppIcon {
|
||||||
|
@Input() cipher: CipherView | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AtRiskPasswordsComponent", () => {
|
||||||
|
let component: AtRiskPasswordsComponent;
|
||||||
|
let fixture: ComponentFixture<AtRiskPasswordsComponent>;
|
||||||
|
|
||||||
|
let mockTasks$: BehaviorSubject<SecurityTask[]>;
|
||||||
|
let mockCiphers$: BehaviorSubject<CipherView[]>;
|
||||||
|
let mockOrgs$: BehaviorSubject<Organization[]>;
|
||||||
|
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
|
||||||
|
let calloutDismissed$: BehaviorSubject<boolean>;
|
||||||
|
const setInlineMenuVisibility = jest.fn();
|
||||||
|
const mockToastService = mock<ToastService>();
|
||||||
|
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
|
||||||
|
{
|
||||||
|
id: "task",
|
||||||
|
organizationId: "org",
|
||||||
|
cipherId: "cipher",
|
||||||
|
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||||
|
} as SecurityTask,
|
||||||
|
]);
|
||||||
|
mockCiphers$ = new BehaviorSubject<CipherView[]>([
|
||||||
|
{
|
||||||
|
id: "cipher",
|
||||||
|
organizationId: "org",
|
||||||
|
name: "Item 1",
|
||||||
|
} as CipherView,
|
||||||
|
{
|
||||||
|
id: "cipher2",
|
||||||
|
organizationId: "org",
|
||||||
|
name: "Item 2",
|
||||||
|
} as CipherView,
|
||||||
|
]);
|
||||||
|
mockOrgs$ = new BehaviorSubject<Organization[]>([
|
||||||
|
{
|
||||||
|
id: "org",
|
||||||
|
name: "Org 1",
|
||||||
|
} as Organization,
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockInlineMenuVisibility$ = new BehaviorSubject<InlineMenuVisibilitySetting>(
|
||||||
|
AutofillOverlayVisibility.Off,
|
||||||
|
);
|
||||||
|
|
||||||
|
calloutDismissed$ = new BehaviorSubject<boolean>(false);
|
||||||
|
setInlineMenuVisibility.mockClear();
|
||||||
|
mockToastService.showToast.mockClear();
|
||||||
|
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AtRiskPasswordsComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TaskService,
|
||||||
|
useValue: {
|
||||||
|
pendingTasks$: () => mockTasks$,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OrganizationService,
|
||||||
|
useValue: {
|
||||||
|
organizations$: () => mockOrgs$,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CipherService,
|
||||||
|
useValue: {
|
||||||
|
cipherViews$: () => mockCiphers$,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||||
|
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
|
||||||
|
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||||
|
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
|
||||||
|
{
|
||||||
|
provide: AutofillSettingsServiceAbstraction,
|
||||||
|
useValue: {
|
||||||
|
inlineMenuVisibility$: mockInlineMenuVisibility$,
|
||||||
|
setInlineMenuVisibility: setInlineMenuVisibility,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: ToastService, useValue: mockToastService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideModule(JslibModule, {
|
||||||
|
remove: {
|
||||||
|
imports: [IconComponent],
|
||||||
|
exports: [IconComponent],
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
imports: [MockAppIcon],
|
||||||
|
exports: [MockAppIcon],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.overrideComponent(AtRiskPasswordsComponent, {
|
||||||
|
remove: {
|
||||||
|
imports: [PopupHeaderComponent, PopupPageComponent],
|
||||||
|
providers: [AtRiskPasswordPageService],
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
imports: [MockPopupHeaderComponent, MockPopupPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AtRiskPasswordsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create", () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pending atRiskItems$", () => {
|
||||||
|
it("should list pending at risk item tasks", async () => {
|
||||||
|
const items = await firstValueFrom(component["atRiskItems$"]);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].name).toBe("Item 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pageDescription$", () => {
|
||||||
|
it("should use single org description when tasks belong to one org", async () => {
|
||||||
|
const description = await firstValueFrom(component["pageDescription$"]);
|
||||||
|
expect(description).toBe("atRiskPasswordsDescSingleOrg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use multiple org description when tasks belong to multiple orgs", async () => {
|
||||||
|
mockTasks$.next([
|
||||||
|
{
|
||||||
|
id: "task",
|
||||||
|
organizationId: "org",
|
||||||
|
cipherId: "cipher",
|
||||||
|
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||||
|
} as SecurityTask,
|
||||||
|
{
|
||||||
|
id: "task2",
|
||||||
|
organizationId: "org2",
|
||||||
|
cipherId: "cipher2",
|
||||||
|
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||||
|
} as SecurityTask,
|
||||||
|
]);
|
||||||
|
const description = await firstValueFrom(component["pageDescription$"]);
|
||||||
|
expect(description).toBe("atRiskPasswordsDescMultiOrg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("autofill callout", () => {
|
||||||
|
it("should show the callout if inline autofill is disabled", async () => {
|
||||||
|
mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off);
|
||||||
|
calloutDismissed$.next(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]'));
|
||||||
|
|
||||||
|
expect(callout).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide the callout if inline autofill is enabled", async () => {
|
||||||
|
mockInlineMenuVisibility$.next(AutofillOverlayVisibility.OnButtonClick);
|
||||||
|
calloutDismissed$.next(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]'));
|
||||||
|
|
||||||
|
expect(callout).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide the callout if the user has previously dismissed it", async () => {
|
||||||
|
mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off);
|
||||||
|
calloutDismissed$.next(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]'));
|
||||||
|
|
||||||
|
expect(callout).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call dismissCallout when the dismiss callout button is clicked", async () => {
|
||||||
|
mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const dismissButton = fixture.debugElement.query(
|
||||||
|
By.css('[data-testid="dismiss-callout-button"]'),
|
||||||
|
);
|
||||||
|
dismissButton.nativeElement.click();
|
||||||
|
expect(mockAtRiskPasswordPageService.dismissCallout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("turn on autofill button", () => {
|
||||||
|
it("should call the service to turn on inline autofill and show a toast", () => {
|
||||||
|
const button = fixture.debugElement.query(
|
||||||
|
By.css('[data-testid="turn-on-autofill-button"]'),
|
||||||
|
);
|
||||||
|
button.nativeElement.click();
|
||||||
|
|
||||||
|
expect(setInlineMenuVisibility).toHaveBeenCalledWith(
|
||||||
|
AutofillOverlayVisibility.OnButtonClick,
|
||||||
|
);
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, inject } from "@angular/core";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import {
|
||||||
|
getOrganizationById,
|
||||||
|
OrganizationService,
|
||||||
|
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||||
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import {
|
||||||
|
BadgeComponent,
|
||||||
|
ButtonModule,
|
||||||
|
CalloutModule,
|
||||||
|
ItemModule,
|
||||||
|
ToastService,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
filterOutNullish,
|
||||||
|
PasswordRepromptService,
|
||||||
|
SecurityTaskType,
|
||||||
|
TaskService,
|
||||||
|
} from "@bitwarden/vault";
|
||||||
|
|
||||||
|
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
||||||
|
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||||
|
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||||
|
|
||||||
|
import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "vault-at-risk-passwords",
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
PopupPageComponent,
|
||||||
|
PopupHeaderComponent,
|
||||||
|
PopOutComponent,
|
||||||
|
ItemModule,
|
||||||
|
CommonModule,
|
||||||
|
JslibModule,
|
||||||
|
BadgeComponent,
|
||||||
|
TypographyModule,
|
||||||
|
CalloutModule,
|
||||||
|
ButtonModule,
|
||||||
|
],
|
||||||
|
providers: [AtRiskPasswordPageService],
|
||||||
|
templateUrl: "./at-risk-passwords.component.html",
|
||||||
|
})
|
||||||
|
export class AtRiskPasswordsComponent {
|
||||||
|
private taskService = inject(TaskService);
|
||||||
|
private organizationService = inject(OrganizationService);
|
||||||
|
private cipherService = inject(CipherService);
|
||||||
|
private i18nService = inject(I18nService);
|
||||||
|
private accountService = inject(AccountService);
|
||||||
|
private platformUtilsService = inject(PlatformUtilsService);
|
||||||
|
private passwordRepromptService = inject(PasswordRepromptService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private autofillSettingsService = inject(AutofillSettingsServiceAbstraction);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
private atRiskPasswordPageService = inject(AtRiskPasswordPageService);
|
||||||
|
|
||||||
|
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||||
|
filterOutNullish(),
|
||||||
|
switchMap((user) =>
|
||||||
|
combineLatest([
|
||||||
|
this.taskService.pendingTasks$(user.id),
|
||||||
|
this.cipherService.cipherViews$(user.id).pipe(
|
||||||
|
filterOutNullish(),
|
||||||
|
map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))),
|
||||||
|
),
|
||||||
|
of(user),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
map(([tasks, ciphers, user]) => ({
|
||||||
|
tasks,
|
||||||
|
ciphers,
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected loading$ = this.activeUserData$.pipe(
|
||||||
|
map(() => false),
|
||||||
|
startWith(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected calloutDismissed$ = this.activeUserData$.pipe(
|
||||||
|
switchMap(({ userId }) => this.atRiskPasswordPageService.isCalloutDismissed(userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected inlineAutofillSettingEnabled$ = this.autofillSettingsService.inlineMenuVisibility$.pipe(
|
||||||
|
map((setting) => setting !== AutofillOverlayVisibility.Off),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected atRiskItems$ = this.activeUserData$.pipe(
|
||||||
|
map(({ tasks, ciphers }) =>
|
||||||
|
tasks
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||||
|
t.cipherId != null &&
|
||||||
|
ciphers[t.cipherId] != null,
|
||||||
|
)
|
||||||
|
.map((t) => ciphers[t.cipherId!]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected pageDescription$ = this.activeUserData$.pipe(
|
||||||
|
switchMap(({ tasks, userId }) => {
|
||||||
|
const orgIds = new Set(tasks.map((t) => t.organizationId));
|
||||||
|
if (orgIds.size === 1) {
|
||||||
|
const [orgId] = orgIds;
|
||||||
|
return this.organizationService.organizations$(userId).pipe(
|
||||||
|
getOrganizationById(orgId),
|
||||||
|
map((org) => this.i18nService.t("atRiskPasswordsDescSingleOrg", org?.name, tasks.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(this.i18nService.t("atRiskPasswordsDescMultiOrg", tasks.length));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async viewCipher(cipher: CipherView) {
|
||||||
|
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||||
|
if (!repromptPassed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.router.navigate(["/view-cipher"], {
|
||||||
|
queryParams: { cipherId: cipher.id, type: cipher.type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchChangePassword(cipher: CipherView) {
|
||||||
|
if (cipher.login?.uri) {
|
||||||
|
this.platformUtilsService.launchUri(cipher.login.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateInlineAutofillMenuVisibility() {
|
||||||
|
await this.autofillSettingsService.setInlineMenuVisibility(
|
||||||
|
AutofillOverlayVisibility.OnButtonClick,
|
||||||
|
);
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
message: this.i18nService.t("turnedOnAutofill"),
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissCallout() {
|
||||||
|
const { userId } = await firstValueFrom(this.activeUserData$);
|
||||||
|
await this.atRiskPasswordPageService.dismissCallout(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
slot="above-scroll-area"
|
slot="above-scroll-area"
|
||||||
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
|
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
|
||||||
>
|
>
|
||||||
|
<vault-at-risk-password-callout
|
||||||
|
*appIfFeature="FeatureFlag.SecurityTasks"
|
||||||
|
></vault-at-risk-password-callout>
|
||||||
<app-vault-header-v2></app-vault-header-v2>
|
<app-vault-header-v2></app-vault-header-v2>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ import {
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
|
import {
|
||||||
|
BannerComponent,
|
||||||
|
ButtonModule,
|
||||||
|
DialogService,
|
||||||
|
Icons,
|
||||||
|
NoItemsModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
|
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
|
||||||
|
|
||||||
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
||||||
@@ -30,6 +37,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
|
|||||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||||
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
||||||
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
|
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
|
||||||
|
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
|
||||||
|
|
||||||
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +75,8 @@ enum VaultState {
|
|||||||
ScrollingModule,
|
ScrollingModule,
|
||||||
VaultHeaderV2Component,
|
VaultHeaderV2Component,
|
||||||
DecryptionFailureDialogComponent,
|
DecryptionFailureDialogComponent,
|
||||||
|
BannerComponent,
|
||||||
|
AtRiskPasswordCalloutComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||||
@@ -168,4 +178,6 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.vaultScrollPositionService.stop();
|
this.vaultScrollPositionService.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly FeatureFlag = FeatureFlag;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,7 +296,12 @@ import {
|
|||||||
DefaultUserAsymmetricKeysRegenerationApiService,
|
DefaultUserAsymmetricKeysRegenerationApiService,
|
||||||
} from "@bitwarden/key-management";
|
} from "@bitwarden/key-management";
|
||||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||||
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
|
import {
|
||||||
|
DefaultTaskService,
|
||||||
|
NewDeviceVerificationNoticeService,
|
||||||
|
PasswordRepromptService,
|
||||||
|
TaskService,
|
||||||
|
} from "@bitwarden/vault";
|
||||||
import {
|
import {
|
||||||
VaultExportService,
|
VaultExportService,
|
||||||
VaultExportServiceAbstraction,
|
VaultExportServiceAbstraction,
|
||||||
@@ -1463,6 +1468,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
useClass: PasswordLoginStrategyData,
|
useClass: PasswordLoginStrategyData,
|
||||||
deps: [],
|
deps: [],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: TaskService,
|
||||||
|
useClass: DefaultTaskService,
|
||||||
|
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi
|
|||||||
export { OrgIconDirective } from "./components/org-icon.directive";
|
export { OrgIconDirective } from "./components/org-icon.directive";
|
||||||
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
||||||
|
|
||||||
|
export * from "./utils/observable-utilities";
|
||||||
|
|
||||||
export * from "./cipher-view";
|
export * from "./cipher-view";
|
||||||
export * from "./cipher-form";
|
export * from "./cipher-form";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault";
|
import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault";
|
||||||
@@ -18,18 +19,26 @@ describe("Default task service", () => {
|
|||||||
|
|
||||||
const mockApiSend = jest.fn();
|
const mockApiSend = jest.fn();
|
||||||
const mockGetAllOrgs$ = jest.fn();
|
const mockGetAllOrgs$ = jest.fn();
|
||||||
|
const mockGetFeatureFlag$ = jest.fn();
|
||||||
|
|
||||||
let testBed: TestBed;
|
let testBed: TestBed;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockApiSend.mockClear();
|
mockApiSend.mockClear();
|
||||||
mockGetAllOrgs$.mockClear();
|
mockGetAllOrgs$.mockClear();
|
||||||
|
mockGetFeatureFlag$.mockClear();
|
||||||
|
|
||||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||||
testBed = TestBed.configureTestingModule({
|
testBed = TestBed.configureTestingModule({
|
||||||
imports: [],
|
imports: [],
|
||||||
providers: [
|
providers: [
|
||||||
DefaultTaskService,
|
DefaultTaskService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
getFeatureFlag$: mockGetFeatureFlag$,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: StateProvider,
|
provide: StateProvider,
|
||||||
useValue: fakeStateProvider,
|
useValue: fakeStateProvider,
|
||||||
@@ -52,6 +61,7 @@ describe("Default task service", () => {
|
|||||||
|
|
||||||
describe("tasksEnabled$", () => {
|
describe("tasksEnabled$", () => {
|
||||||
it("should emit true if any organization uses risk insights", async () => {
|
it("should emit true if any organization uses risk insights", async () => {
|
||||||
|
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||||
mockGetAllOrgs$.mockReturnValue(
|
mockGetAllOrgs$.mockReturnValue(
|
||||||
new BehaviorSubject([
|
new BehaviorSubject([
|
||||||
{
|
{
|
||||||
@@ -71,6 +81,7 @@ describe("Default task service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should emit false if no organization uses risk insights", async () => {
|
it("should emit false if no organization uses risk insights", async () => {
|
||||||
|
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||||
mockGetAllOrgs$.mockReturnValue(
|
mockGetAllOrgs$.mockReturnValue(
|
||||||
new BehaviorSubject([
|
new BehaviorSubject([
|
||||||
{
|
{
|
||||||
@@ -88,6 +99,23 @@ describe("Default task service", () => {
|
|||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should emit false if the feature flag is off", async () => {
|
||||||
|
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
|
||||||
|
mockGetAllOrgs$.mockReturnValue(
|
||||||
|
new BehaviorSubject([
|
||||||
|
{
|
||||||
|
useRiskInsights: true,
|
||||||
|
},
|
||||||
|
] as Organization[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tasks$", () => {
|
describe("tasks$", () => {
|
||||||
@@ -100,7 +128,7 @@ describe("Default task service", () => {
|
|||||||
] as SecurityTaskResponse[],
|
] as SecurityTaskResponse[],
|
||||||
});
|
});
|
||||||
|
|
||||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null);
|
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any);
|
||||||
|
|
||||||
const { tasks$ } = testBed.inject(DefaultTaskService);
|
const { tasks$ } = testBed.inject(DefaultTaskService);
|
||||||
|
|
||||||
@@ -183,7 +211,11 @@ describe("Default task service", () => {
|
|||||||
] as SecurityTaskResponse[],
|
] as SecurityTaskResponse[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null);
|
const mock = fakeStateProvider.singleUser.mockFor(
|
||||||
|
"user-id" as UserId,
|
||||||
|
SECURITY_TASKS,
|
||||||
|
null as any,
|
||||||
|
);
|
||||||
|
|
||||||
const service = testBed.inject(DefaultTaskService);
|
const service = testBed.inject(DefaultTaskService);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import { map, switchMap } from "rxjs";
|
import { combineLatest, map, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault";
|
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault";
|
||||||
@@ -19,12 +21,16 @@ export class DefaultTaskService implements TaskService {
|
|||||||
private stateProvider: StateProvider,
|
private stateProvider: StateProvider,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
tasksEnabled$ = perUserCache$((userId) => {
|
tasksEnabled$ = perUserCache$((userId) => {
|
||||||
return this.organizationService
|
return combineLatest([
|
||||||
.organizations$(userId)
|
this.organizationService
|
||||||
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights)));
|
.organizations$(userId)
|
||||||
|
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))),
|
||||||
|
this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks),
|
||||||
|
]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled));
|
||||||
});
|
});
|
||||||
|
|
||||||
tasks$ = perUserCache$((userId) => {
|
tasks$ = perUserCache$((userId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user