mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
[PM-18984] Vault Nudges Service (#13970)
* build vault nudge service for upcoming onboarding nudges
This commit is contained in:
@@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
|||||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||||
|
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export * from "./components/carousel";
|
|||||||
|
|
||||||
export * as VaultIcons from "./icons";
|
export * as VaultIcons from "./icons";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
|
export * from "./services/vault-nudges.service";
|
||||||
|
|
||||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { map, Observable, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
|
||||||
|
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||||
|
import { VaultNudgeType } from "../vault-nudges.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Nudge Service to use for the Onboarding Nudges in the Vault
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class HasItemsNudgeService extends DefaultSingleNudgeService {
|
||||||
|
cipherService = inject(CipherService);
|
||||||
|
|
||||||
|
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||||
|
return this.isDismissed$(nudgeType, userId).pipe(
|
||||||
|
switchMap((dismissed) =>
|
||||||
|
dismissed
|
||||||
|
? of(false)
|
||||||
|
: this.cipherService
|
||||||
|
.cipherViews$(userId)
|
||||||
|
.pipe(map((ciphers) => ciphers == null || ciphers.length === 0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
libs/vault/src/services/custom-nudges-services/index.ts
Normal file
1
libs/vault/src/services/custom-nudges-services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./has-items-nudge.service";
|
||||||
52
libs/vault/src/services/default-single-nudge.service.ts
Normal file
52
libs/vault/src/services/default-single-nudge.service.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { map, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
import { VAULT_NUDGE_DISMISSED_DISK_KEY, VaultNudgeType } from "./vault-nudges.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for handling a nudge's status
|
||||||
|
*/
|
||||||
|
export interface SingleNudgeService {
|
||||||
|
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean>;
|
||||||
|
|
||||||
|
setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation for nudges. Set and Show Nudge dismissed state
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class DefaultSingleNudgeService implements SingleNudgeService {
|
||||||
|
stateProvider = inject(StateProvider);
|
||||||
|
|
||||||
|
protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||||
|
return this.stateProvider
|
||||||
|
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
|
||||||
|
.state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false));
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||||
|
return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNudgeStatus(
|
||||||
|
nudgeType: VaultNudgeType,
|
||||||
|
dismissed: boolean,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
|
||||||
|
nudges ??= [];
|
||||||
|
if (dismissed) {
|
||||||
|
nudges.push(nudgeType);
|
||||||
|
} else {
|
||||||
|
nudges = nudges.filter((n) => n !== nudgeType);
|
||||||
|
}
|
||||||
|
return nudges;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
102
libs/vault/src/services/vault-nudges.service.spec.ts
Normal file
102
libs/vault/src/services/vault-nudges.service.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { TestBed } from "@angular/core/testing";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { firstValueFrom, of } from "rxjs";
|
||||||
|
|
||||||
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
|
||||||
|
|
||||||
|
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
|
||||||
|
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||||
|
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
|
||||||
|
|
||||||
|
describe("Vault Nudges Service", () => {
|
||||||
|
let fakeStateProvider: FakeStateProvider;
|
||||||
|
|
||||||
|
let testBed: TestBed;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||||
|
|
||||||
|
testBed = TestBed.configureTestingModule({
|
||||||
|
imports: [],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: VaultNudgesService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DefaultSingleNudgeService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: StateProvider,
|
||||||
|
useValue: fakeStateProvider,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HasItemsNudgeService,
|
||||||
|
useValue: mock<HasItemsNudgeService>(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DefaultSingleNudgeService", () => {
|
||||||
|
it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => {
|
||||||
|
const service = testBed.inject(DefaultSingleNudgeService);
|
||||||
|
|
||||||
|
await service.setNudgeStatus(
|
||||||
|
VaultNudgeType.IntroCarouselDismissal,
|
||||||
|
true,
|
||||||
|
"user-id" as UserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => {
|
||||||
|
const service = testBed.inject(DefaultSingleNudgeService);
|
||||||
|
|
||||||
|
await service.setNudgeStatus(
|
||||||
|
VaultNudgeType.IntroCarouselDismissal,
|
||||||
|
false,
|
||||||
|
"user-id" as UserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VaultNudgesService", () => {
|
||||||
|
it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => {
|
||||||
|
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||||
|
useValue: { shouldShowNudge$: () => of(true) },
|
||||||
|
});
|
||||||
|
const service = testBed.inject(VaultNudgesService);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => {
|
||||||
|
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||||
|
useValue: { shouldShowNudge$: () => of(false) },
|
||||||
|
});
|
||||||
|
const service = testBed.inject(VaultNudgesService);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
libs/vault/src/services/vault-nudges.service.ts
Normal file
70
libs/vault/src/services/vault-nudges.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
|
||||||
|
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
|
||||||
|
*/
|
||||||
|
export enum VaultNudgeType {
|
||||||
|
/** Nudge to show when user has no items in their vault
|
||||||
|
* Add future nudges here
|
||||||
|
*/
|
||||||
|
HasVaultItems = "has-vault-items",
|
||||||
|
IntroCarouselDismissal = "intro-carousel-dismissal",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<VaultNudgeType[]>(
|
||||||
|
VAULT_NUDGES_DISK,
|
||||||
|
"vaultNudgeDismissed",
|
||||||
|
{
|
||||||
|
deserializer: (nudgeDismissed) => nudgeDismissed,
|
||||||
|
clearOn: [], // Do not clear dismissals
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class VaultNudgesService {
|
||||||
|
/**
|
||||||
|
* Custom nudge services to use for specific nudge types
|
||||||
|
* Each nudge type can have its own service to determine when to show the nudge
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private customNudgeServices: any = {
|
||||||
|
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default nudge service to use when no custom service is available
|
||||||
|
* Simply stores the dismissed state in the user's state
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private defaultNudgeService = inject(DefaultSingleNudgeService);
|
||||||
|
|
||||||
|
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
|
||||||
|
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a nudge should be shown to the user
|
||||||
|
* @param nudge
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
|
showNudge$(nudge: VaultNudgeType, userId: UserId) {
|
||||||
|
return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a nudge for the user so that it is not shown again
|
||||||
|
* @param nudge
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
|
dismissNudge(nudge: VaultNudgeType, userId: UserId) {
|
||||||
|
return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user