1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +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

@@ -1,3 +1,7 @@
export {
AtRiskPasswordCalloutService,
AtRiskPasswordCalloutData,
} from "./services/at-risk-password-callout.service";
export { PasswordRepromptService } from "./services/password-reprompt.service";
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";

View File

@@ -0,0 +1,162 @@
import { TestBed } from "@angular/core/testing";
import { firstValueFrom, of } from "rxjs";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
SecurityTask,
SecurityTaskStatus,
SecurityTaskType,
TaskService,
} from "@bitwarden/common/vault/tasks";
import { StateProvider } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
import { FakeSingleUserState } from "../../../common/spec/fake-state";
import {
AtRiskPasswordCalloutData,
AtRiskPasswordCalloutService,
} from "./at-risk-password-callout.service";
const fakeUserState = () =>
({
update: jest.fn().mockResolvedValue(undefined),
state$: of(null),
}) as unknown as FakeSingleUserState<AtRiskPasswordCalloutData>;
class MockCipherView {
constructor(
public id: string,
private deleted: boolean,
) {}
get isDeleted() {
return this.deleted;
}
}
describe("AtRiskPasswordCalloutService", () => {
let service: AtRiskPasswordCalloutService;
const mockTaskService = {
pendingTasks$: jest.fn(),
completedTasks$: jest.fn(),
};
const mockCipherService = { cipherViews$: jest.fn() };
const mockStateProvider = { getUser: jest.fn().mockReturnValue(fakeUserState()) };
const userId: UserId = "user1" as UserId;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AtRiskPasswordCalloutService,
{
provide: TaskService,
useValue: mockTaskService,
},
{
provide: CipherService,
useValue: mockCipherService,
},
{
provide: StateProvider,
useValue: mockStateProvider,
},
],
});
service = TestBed.inject(AtRiskPasswordCalloutService);
});
describe("completedTasks$", () => {
it(" should return true if completed tasks exist", async () => {
const tasks: SecurityTask[] = [
{
id: "t1",
cipherId: "c1",
type: SecurityTaskType.UpdateAtRiskCredential,
status: SecurityTaskStatus.Completed,
} as any,
{
id: "t2",
cipherId: "c2",
type: SecurityTaskType.UpdateAtRiskCredential,
status: SecurityTaskStatus.Pending,
} as any,
{
id: "t3",
cipherId: "nope",
type: SecurityTaskType.UpdateAtRiskCredential,
status: SecurityTaskStatus.Completed,
} as any,
{
id: "t4",
cipherId: "c3",
type: SecurityTaskType.UpdateAtRiskCredential,
status: SecurityTaskStatus.Completed,
} as any,
];
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks));
const result = await firstValueFrom(service.completedTasks$(userId));
expect(result).toEqual(tasks[0]);
expect(result?.id).toBe("t1");
});
});
describe("showCompletedTasksBanner$", () => {
beforeEach(() => {
jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of([]));
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of([]));
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([]));
});
it("should return false if banner has been dismissed", async () => {
const state: AtRiskPasswordCalloutData = {
hasInteractedWithTasks: true,
tasksBannerDismissed: true,
};
const mockState = { ...fakeUserState(), state$: of(state) };
mockStateProvider.getUser.mockReturnValue(mockState);
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
expect(result).toBe(false);
});
it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => {
const completedTasks = [
{
id: "t1",
cipherId: "c1",
type: SecurityTaskType.UpdateAtRiskCredential,
status: SecurityTaskStatus.Completed,
},
];
const ciphers = [new MockCipherView("c1", false)];
const state: AtRiskPasswordCalloutData = {
hasInteractedWithTasks: true,
tasksBannerDismissed: false,
};
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(completedTasks));
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers));
mockStateProvider.getUser.mockReturnValue({ state$: of(state) });
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
expect(result).toBe(true);
});
it("returns false when no completed tasks", async () => {
const state: AtRiskPasswordCalloutData = {
hasInteractedWithTasks: true,
tasksBannerDismissed: false,
};
mockStateProvider.getUser.mockReturnValue({ state$: of(state) });
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,93 @@
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import {
SingleUserState,
StateProvider,
UserKeyDefinition,
VAULT_AT_RISK_PASSWORDS_MEMORY,
} from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { UserId } from "@bitwarden/user-core";
export type AtRiskPasswordCalloutData = {
hasInteractedWithTasks: boolean;
tasksBannerDismissed: boolean;
};
export const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition<AtRiskPasswordCalloutData>(
VAULT_AT_RISK_PASSWORDS_MEMORY,
"atRiskPasswords",
{
deserializer: (jsonData) => jsonData,
clearOn: ["lock", "logout"],
},
);
@Injectable()
export class AtRiskPasswordCalloutService {
constructor(
private taskService: TaskService,
private cipherService: CipherService,
private stateProvider: StateProvider,
) {}
pendingTasks$(userId: UserId): Observable<SecurityTask[]> {
return combineLatest([
this.taskService.pendingTasks$(userId),
this.cipherService.cipherViews$(userId),
]).pipe(
map(([tasks, ciphers]) => {
return tasks.filter((t: SecurityTask) => {
const associatedCipher = ciphers.find((c) => c.id === t.cipherId);
return (
t.type === SecurityTaskType.UpdateAtRiskCredential &&
associatedCipher &&
!associatedCipher.isDeleted
);
});
}),
);
}
completedTasks$(userId: UserId): Observable<SecurityTask | undefined> {
return this.taskService.completedTasks$(userId).pipe(
map((tasks) => {
return tasks.find((t: SecurityTask) => t.type === SecurityTaskType.UpdateAtRiskCredential);
}),
);
}
showCompletedTasksBanner$(userId: UserId): Observable<boolean> {
return combineLatest([
this.pendingTasks$(userId),
this.completedTasks$(userId),
this.atRiskPasswordState(userId).state$,
]).pipe(
map(([pendingTasks, completedTasks, state]) => {
const hasPendingTasks = pendingTasks.length > 0;
const bannerDismissed = state?.tasksBannerDismissed ?? false;
const hasInteracted = state?.hasInteractedWithTasks ?? false;
// This will ensure the banner remains visible only in the client the user resolved their tasks in
// e.g. if the user did not see tasks in the browser, and resolves them in the web, the browser will not show the banner
if (!hasPendingTasks && (!hasInteracted || bannerDismissed)) {
return false;
}
// Show banner if there are completed tasks and no pending tasks, and banner hasn't been dismissed
return !!completedTasks && !hasPendingTasks && !(state?.tasksBannerDismissed ?? false);
}),
);
}
atRiskPasswordState(userId: UserId): SingleUserState<AtRiskPasswordCalloutData> {
return this.stateProvider.getUser(userId, AT_RISK_PASSWORD_CALLOUT_KEY);
}
updateAtRiskPasswordState(userId: UserId, updatedState: AtRiskPasswordCalloutData): void {
void this.atRiskPasswordState(userId).update(() => updatedState);
}
}