1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-20040] all tasks complete banner (#16033)

* saved WIP

* created at risk password callout service to hold state for callout data. wip

* update at-risk-password-callout to use states for tracking showing and dismissing success banner

* adding spec file for new serive

* update styles to match figma

* minor wording changes

* fix undefined lint error in at risk password callout

* moved service to libs

* added another route guard so when user clears all at risk items they are directed back to the vault page

* small cleanup in at risk callout component and at risk pw guard

* clean up code in at risk password callout component

* update state to memory

* refactor for readability at risk password callout component

* move state update logic from component to at risk password callout service

* fix: bypass router cache on back() in popout

* Revert "fix: bypass router cache on back() in popout"

This reverts commit 23f9312434.

* refactor updatePendingTasksState call

* refactor at risk password callout component and service. remove signals, implement logic through observables. Completed value for tasks utilized.

* clean up completedTasks in at risk password callout service

* add updated state value to prevent banner among diff clients

* move hasInteracted call to page component to avoid looping

* remove excess call in service

* update icon null logic in banner component

* update the callout to use a new banner

* fix classes

* updating banners in at risk password callout component

* anchor tag

* move at-risk callout to above nudges

* update `showCompletedTasksBanner$` variable naming

---------

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
Jason Ng
2025-10-22 12:37:58 -04:00
committed by GitHub
parent 3812e5d81b
commit 0340a881ae
15 changed files with 405 additions and 41 deletions

View File

@@ -5715,6 +5715,9 @@
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
},
"atRiskLoginsSecured": {
"message": "Great job securing your at-risk logins!"
},
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."

View File

@@ -77,7 +77,10 @@ import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro
import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component";
import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component";
import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component";
import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-passwords.guard";
import {
canAccessAtRiskPasswords,
hasAtRiskPasswords,
} from "../vault/popup/guards/at-risk-passwords.guard";
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
@@ -692,7 +695,7 @@ const routes: Routes = [
{
path: "at-risk-passwords",
component: AtRiskPasswordsComponent,
canActivate: [authGuard, canAccessAtRiskPasswords],
canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords],
},
{
path: "account-switcher",

View File

@@ -1,8 +1,28 @@
<bit-callout *ngIf="(pendingTasks$ | async)?.length as taskCount" type="warning" [title]="''">
<a bitLink [routerLink]="'/at-risk-passwords'">
{{
(taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural")
| i18n: taskCount.toString()
}}
</a>
</bit-callout>
@if ((currentPendingTasks$ | async)?.length > 0) {
<bit-banner
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
bannerType="warning"
[showClose]="false"
>
<a bitLink linkType="secondary" [routerLink]="'/at-risk-passwords'">
{{
((currentPendingTasks$ | async)?.length === 1
? "reviewAndChangeAtRiskPassword"
: "reviewAndChangeAtRiskPasswordsPlural"
) | i18n: (currentPendingTasks$ | async)?.length.toString()
}}
</a>
</bit-banner>
}
@if (showCompletedTasksBanner$ | async) {
<bit-banner
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
bannerType="info"
[icon]="null"
[showClose]="true"
(onClose)="successBannerDismissed()"
>
{{ "atRiskLoginsSecured" | i18n }}
</bit-banner>
}

View File

@@ -1,42 +1,47 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
import { combineLatest, map, switchMap } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
@Component({
selector: "vault-at-risk-password-callout",
imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe],
imports: [
AnchorLinkDirective,
CommonModule,
RouterModule,
CalloutModule,
I18nPipe,
BannerModule,
JslibModule,
],
providers: [AtRiskPasswordCalloutService],
templateUrl: "./at-risk-password-callout.component.html",
})
export class AtRiskPasswordCalloutComponent {
private taskService = inject(TaskService);
private cipherService = inject(CipherService);
private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
protected pendingTasks$ = this.activeAccount$.pipe(
switchMap((userId) =>
combineLatest([
this.taskService.pendingTasks$(userId),
this.cipherService.cipherViews$(userId),
]),
),
map(([tasks, ciphers]) =>
tasks.filter((t) => {
const associatedCipher = ciphers.find((c) => c.id === t.cipherId);
return (
t.type === SecurityTaskType.UpdateAtRiskCredential &&
associatedCipher &&
!associatedCipher.isDeleted
);
}),
),
showCompletedTasksBanner$ = this.activeAccount$.pipe(
switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)),
);
currentPendingTasks$ = this.activeAccount$.pipe(
switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)),
);
async successBannerDismissed() {
const updateObject: AtRiskPasswordCalloutData = {
hasInteractedWithTasks: true,
tasksBannerDismissed: true,
};
const userId = await firstValueFrom(this.activeAccount$);
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject);
}
}

View File

@@ -14,6 +14,9 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
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 { StateProvider } from "@bitwarden/common/platform/state";
import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
@@ -24,6 +27,7 @@ import {
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
PasswordRepromptService,
AtRiskPasswordCalloutService,
} from "@bitwarden/vault";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
@@ -68,6 +72,9 @@ describe("AtRiskPasswordsComponent", () => {
let mockNotifications$: BehaviorSubject<NotificationView[]>;
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
let calloutDismissed$: BehaviorSubject<boolean>;
let mockAtRiskPasswordCalloutService: any;
let stateProvider: FakeStateProvider;
let mockAccountService: FakeAccountService;
const setInlineMenuVisibility = jest.fn();
const mockToastService = mock<ToastService>();
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
@@ -112,6 +119,11 @@ describe("AtRiskPasswordsComponent", () => {
mockToastService.showToast.mockClear();
mockDialogService.open.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
mockAccountService = {
activeAccount$: of({ id: "user" as UserId }),
activeUserId: "user" as UserId,
} as unknown as FakeAccountService;
stateProvider = new FakeStateProvider(mockAccountService);
await TestBed.configureTestingModule({
imports: [AtRiskPasswordsComponent],
@@ -141,7 +153,7 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{
@@ -152,6 +164,8 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: ToastService, useValue: mockToastService },
{ provide: StateProvider, useValue: stateProvider },
{ provide: AtRiskPasswordCalloutService, useValue: mockAtRiskPasswordCalloutService },
],
})
.overrideModule(JslibModule, {

View File

@@ -41,6 +41,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import {
AtRiskPasswordCalloutService,
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
PasswordRepromptService,
@@ -75,6 +76,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
providers: [
AtRiskPasswordPageService,
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
AtRiskPasswordCalloutService,
],
selector: "vault-at-risk-passwords",
templateUrl: "./at-risk-passwords.component.html",
@@ -95,6 +97,7 @@ export class AtRiskPasswordsComponent implements OnInit {
private dialogService = inject(DialogService);
private endUserNotificationService = inject(EndUserNotificationService);
private destroyRef = inject(DestroyRef);
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
/**
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
@@ -199,6 +202,11 @@ export class AtRiskPasswordsComponent implements OnInit {
}
this.markTaskNotificationsAsRead();
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, {
hasInteractedWithTasks: true,
tasksBannerDismissed: false,
});
}
private markTaskNotificationsAsRead() {

View File

@@ -41,6 +41,9 @@
</bit-spotlight>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
<!-- At-Risk callout displays as a banner and should always remain visually above all other callouts -->
<vault-at-risk-password-callout></vault-at-risk-password-callout>
<div class="tw-mb-4" *ngIf="showHasItemsVaultSpotlight$ | async">
<bit-spotlight
[title]="'hasItemsVaultNudgeTitle' | i18n"
@@ -53,7 +56,6 @@
</ul>
</bit-spotlight>
</div>
<vault-at-risk-password-callout></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
</ng-container>
</ng-container>

View File

@@ -1,10 +1,11 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { map, switchMap } from "rxjs";
import { combineLatest, map, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { ToastService } from "@bitwarden/components";
@@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => {
}),
);
};
export const hasAtRiskPasswords: CanActivateFn = () => {
const accountService = inject(AccountService);
const taskService = inject(TaskService);
const cipherService = inject(CipherService);
const router = inject(Router);
return accountService.activeAccount$.pipe(
filterOutNullish(),
switchMap((user) =>
combineLatest([
taskService.pendingTasks$(user.id),
cipherService.cipherViews$(user.id).pipe(
filterOutNullish(),
map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))),
),
]).pipe(
map(([tasks, ciphers]) => {
const hasAtRiskCiphers = tasks.some(
(t) =>
t.type === SecurityTaskType.UpdateAtRiskCredential &&
t.cipherId != null &&
ciphers[t.cipherId] != null &&
!ciphers[t.cipherId].isDeleted,
);
if (!hasAtRiskCiphers) {
return router.createUrlTree(["/tabs/vault"]);
}
return true;
}),
),
),
);
};