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:
@@ -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";
|
||||
|
||||
162
libs/vault/src/services/at-risk-password-callout.service.spec.ts
Normal file
162
libs/vault/src/services/at-risk-password-callout.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
libs/vault/src/services/at-risk-password-callout.service.ts
Normal file
93
libs/vault/src/services/at-risk-password-callout.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user