mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +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": {
|
||||
"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": {
|
||||
"message": "Website $number$ (URI)",
|
||||
"placeholders": {
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
activeAuthGuard,
|
||||
authGuard,
|
||||
lockGuard,
|
||||
activeAuthGuard,
|
||||
redirectGuard,
|
||||
tdeDecryptionRequiredGuard,
|
||||
unauthGuardFn,
|
||||
@@ -23,10 +23,14 @@ import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guard
|
||||
import {
|
||||
AnonLayoutWrapperComponent,
|
||||
AnonLayoutWrapperData,
|
||||
LoginComponent,
|
||||
LoginSecondaryContentComponent,
|
||||
DevicesIcon,
|
||||
DeviceVerificationIcon,
|
||||
LockIcon,
|
||||
LoginComponent,
|
||||
LoginDecryptionOptionsComponent,
|
||||
LoginSecondaryContentComponent,
|
||||
LoginViaAuthRequestComponent,
|
||||
NewDeviceVerificationComponent,
|
||||
PasswordHintComponent,
|
||||
RegistrationFinishComponent,
|
||||
RegistrationLockAltIcon,
|
||||
@@ -35,14 +39,10 @@ import {
|
||||
RegistrationStartSecondaryComponentData,
|
||||
RegistrationUserAddIcon,
|
||||
SetPasswordJitComponent,
|
||||
UserLockIcon,
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
DevicesIcon,
|
||||
SsoComponent,
|
||||
TwoFactorTimeoutIcon,
|
||||
NewDeviceVerificationComponent,
|
||||
DeviceVerificationIcon,
|
||||
UserLockIcon,
|
||||
VaultIcon,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
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 { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-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 { 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 { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.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",
|
||||
component: AccountSwitcherComponent,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
|
||||
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 { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
@@ -11,21 +11,21 @@ import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/sa
|
||||
import {
|
||||
CLIENT_TYPE,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
ENV_ADDITIONAL_REGIONS,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
MEMORY_STORAGE,
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
OBSERVABLE_MEMORY_STORAGE,
|
||||
SafeInjectionToken,
|
||||
SECURE_STORAGE,
|
||||
SYSTEM_THEME_OBSERVABLE,
|
||||
SafeInjectionToken,
|
||||
ENV_ADDITIONAL_REGIONS,
|
||||
} from "@bitwarden/angular/services/injection-tokens";
|
||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
LoginComponentService,
|
||||
SsoComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
SsoComponentService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
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 { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
import {
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
BiometricsService,
|
||||
DefaultKeyService,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
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"
|
||||
*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>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -17,10 +17,17 @@ import {
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
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 { 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 { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.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 {
|
||||
@@ -67,6 +75,8 @@ enum VaultState {
|
||||
ScrollingModule,
|
||||
VaultHeaderV2Component,
|
||||
DecryptionFailureDialogComponent,
|
||||
BannerComponent,
|
||||
AtRiskPasswordCalloutComponent,
|
||||
],
|
||||
})
|
||||
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
@@ -168,4 +178,6 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.vaultScrollPositionService.stop();
|
||||
}
|
||||
|
||||
protected readonly FeatureFlag = FeatureFlag;
|
||||
}
|
||||
|
||||
@@ -296,7 +296,12 @@ import {
|
||||
DefaultUserAsymmetricKeysRegenerationApiService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
DefaultTaskService,
|
||||
NewDeviceVerificationNoticeService,
|
||||
PasswordRepromptService,
|
||||
TaskService,
|
||||
} from "@bitwarden/vault";
|
||||
import {
|
||||
VaultExportService,
|
||||
VaultExportServiceAbstraction,
|
||||
@@ -1463,6 +1468,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: PasswordLoginStrategyData,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaskService,
|
||||
useClass: DefaultTaskService,
|
||||
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -5,6 +5,8 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi
|
||||
export { OrgIconDirective } from "./components/org-icon.directive";
|
||||
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
||||
|
||||
export * from "./utils/observable-utilities";
|
||||
|
||||
export * from "./cipher-view";
|
||||
export * from "./cipher-form";
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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 { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault";
|
||||
@@ -18,18 +19,26 @@ describe("Default task service", () => {
|
||||
|
||||
const mockApiSend = jest.fn();
|
||||
const mockGetAllOrgs$ = jest.fn();
|
||||
const mockGetFeatureFlag$ = jest.fn();
|
||||
|
||||
let testBed: TestBed;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiSend.mockClear();
|
||||
mockGetAllOrgs$.mockClear();
|
||||
mockGetFeatureFlag$.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
testBed = TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
DefaultTaskService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag$: mockGetFeatureFlag$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
@@ -52,6 +61,7 @@ describe("Default task service", () => {
|
||||
|
||||
describe("tasksEnabled$", () => {
|
||||
it("should emit true if any organization uses risk insights", async () => {
|
||||
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
@@ -71,6 +81,7 @@ describe("Default task service", () => {
|
||||
});
|
||||
|
||||
it("should emit false if no organization uses risk insights", async () => {
|
||||
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
@@ -88,6 +99,23 @@ describe("Default task service", () => {
|
||||
|
||||
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$", () => {
|
||||
@@ -100,7 +128,7 @@ describe("Default task service", () => {
|
||||
] 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);
|
||||
|
||||
@@ -183,7 +211,11 @@ describe("Default task service", () => {
|
||||
] 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);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { 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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault";
|
||||
@@ -19,12 +21,16 @@ export class DefaultTaskService implements TaskService {
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
tasksEnabled$ = perUserCache$((userId) => {
|
||||
return this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights)));
|
||||
return combineLatest([
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))),
|
||||
this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks),
|
||||
]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled));
|
||||
});
|
||||
|
||||
tasks$ = perUserCache$((userId) => {
|
||||
|
||||
Reference in New Issue
Block a user