1
0
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:
Shane Melton
2025-02-12 13:28:20 -08:00
committed by GitHub
parent 6d61d08d44
commit 96260eda65
16 changed files with 763 additions and 23 deletions

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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";

View 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"),
});
}
}),
);
};

View File

@@ -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>

View File

@@ -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)),
),
),
);
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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();
});
});
});
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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) => {