mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-18800] vault onboarding nudges and badge (#14278)
* added empty vault nudge service and has items vault nudge service with spotlight and settings badge to vault v2 in browser * Refactor Vault Nudge Service for clarity between spotlight and badge dismissals
This commit is contained in:
@@ -32,3 +32,5 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
|
||||
export { SpotlightComponent } from "./components/spotlight/spotlight.component";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service Checking Nudge Status For Empty Vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
cipherService = inject(CipherService);
|
||||
organizationService = inject(OrganizationService);
|
||||
collectionService = inject(CollectionService);
|
||||
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.collectionService.decryptedCollections$,
|
||||
]).pipe(
|
||||
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
|
||||
const emptyVault = ciphers == null || ciphers.length === 0;
|
||||
if (orgs == null || orgs.length === 0) {
|
||||
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
|
||||
? of(nudgeStatus)
|
||||
: of({
|
||||
hasSpotlightDismissed: emptyVault,
|
||||
hasBadgeDismissed: emptyVault,
|
||||
});
|
||||
}
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
);
|
||||
// Do not show nudge when
|
||||
// user has previously dismissed nudge
|
||||
// OR
|
||||
// user belongs to an organization and cannot create collections || manage collections
|
||||
if (
|
||||
nudgeStatus.hasBadgeDismissed ||
|
||||
nudgeStatus.hasSpotlightDismissed ||
|
||||
hasManageCollections ||
|
||||
canCreateCollections
|
||||
) {
|
||||
return of(nudgeStatus);
|
||||
}
|
||||
return of({
|
||||
hasSpotlightDismissed: emptyVault,
|
||||
hasBadgeDismissed: emptyVault,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,49 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { map, Observable, of, switchMap } from "rxjs";
|
||||
import { combineLatest, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
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";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service to use for the Onboarding Nudges in the Vault
|
||||
* Custom Nudge Service Checking Nudge Status For Welcome Nudge With Populated Vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class HasItemsNudgeService extends DefaultSingleNudgeService {
|
||||
cipherService = inject(CipherService);
|
||||
vaultProfileService = inject(VaultProfileService);
|
||||
logService = inject(LogService);
|
||||
|
||||
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)),
|
||||
),
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
]).pipe(
|
||||
switchMap(async ([ciphers, nudgeStatus]) => {
|
||||
try {
|
||||
const creationDate = await this.vaultProfileService.getProfileCreationDate(userId);
|
||||
const thirtyDays = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const isRecentAcct = creationDate >= thirtyDays;
|
||||
|
||||
if (!isRecentAcct || nudgeStatus.hasSpotlightDismissed) {
|
||||
return nudgeStatus;
|
||||
} else {
|
||||
return {
|
||||
hasBadgeDismissed: ciphers == null || ciphers.length === 0,
|
||||
hasSpotlightDismissed: ciphers == null || ciphers.length === 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to fetch profile creation date: ", error);
|
||||
return nudgeStatus;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { VaultNudgeType } from "../vault-nudges.service";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service used for showing if the user has any existing nudge in the Vault.
|
||||
@@ -17,6 +17,7 @@ export class HasNudgeService extends DefaultSingleNudgeService {
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
private nudgeTypes: VaultNudgeType[] = [
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
VaultNudgeType.HasVaultItems,
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
// add additional nudge types here as needed
|
||||
@@ -25,20 +26,25 @@ export class HasNudgeService extends DefaultSingleNudgeService {
|
||||
/**
|
||||
* Returns an observable that emits true if any of the provided nudge types are present
|
||||
*/
|
||||
shouldShowNudge$(): Observable<boolean> {
|
||||
nudgeStatus$(): Observable<NudgeStatus> {
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
switchMap((activeAccount) => {
|
||||
const userId: UserId | undefined = activeAccount?.id;
|
||||
if (!userId) {
|
||||
return of(false);
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
|
||||
}
|
||||
|
||||
const nudgeObservables: Observable<boolean>[] = this.nudgeTypes.map((nudge) =>
|
||||
super.shouldShowNudge$(nudge, userId),
|
||||
const nudgeObservables: Observable<NudgeStatus>[] = this.nudgeTypes.map((nudge) =>
|
||||
super.nudgeStatus$(nudge, userId),
|
||||
);
|
||||
|
||||
return combineLatest(nudgeObservables).pipe(
|
||||
map((nudgeStates) => nudgeStates.some((state) => state)),
|
||||
map((nudgeStates) => {
|
||||
return {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: nudgeStates.some((state) => state.hasSpotlightDismissed),
|
||||
};
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./has-items-nudge.service";
|
||||
export * from "./empty-vault-nudge.service";
|
||||
export * from "./has-nudge.service";
|
||||
|
||||
@@ -4,15 +4,19 @@ 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";
|
||||
import {
|
||||
NudgeStatus,
|
||||
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>;
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus>;
|
||||
|
||||
setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise<void>;
|
||||
setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,28 +28,29 @@ export interface SingleNudgeService {
|
||||
export class DefaultSingleNudgeService implements SingleNudgeService {
|
||||
stateProvider = inject(StateProvider);
|
||||
|
||||
protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||
protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return this.stateProvider
|
||||
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
|
||||
.state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false));
|
||||
.state$.pipe(
|
||||
map(
|
||||
(nudges) =>
|
||||
nudges?.[nudgeType] ?? { hasBadgeDismissed: false, hasSpotlightDismissed: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed));
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return this.getNudgeStatus$(nudgeType, userId);
|
||||
}
|
||||
|
||||
async setNudgeStatus(
|
||||
nudgeType: VaultNudgeType,
|
||||
dismissed: boolean,
|
||||
status: NudgeStatus,
|
||||
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);
|
||||
}
|
||||
nudges ??= {};
|
||||
nudges[nudgeType] = status;
|
||||
return nudges;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
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 { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
|
||||
|
||||
@@ -15,6 +16,10 @@ describe("Vault Nudges Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
let testBed: TestBed;
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
|
||||
getFeatureFlag: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
@@ -32,50 +37,55 @@ describe("Vault Nudges Service", () => {
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{
|
||||
provide: HasItemsNudgeService,
|
||||
useValue: mock<HasItemsNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: EmptyVaultNudgeService,
|
||||
useValue: mock<EmptyVaultNudgeService>(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultSingleNudgeService", () => {
|
||||
it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => {
|
||||
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is true", async () => {
|
||||
const service = testBed.inject(DefaultSingleNudgeService);
|
||||
|
||||
await service.setNudgeStatus(
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
true,
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
{ hasBadgeDismissed: true, hasSpotlightDismissed: true },
|
||||
"user-id" as UserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
|
||||
});
|
||||
|
||||
it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => {
|
||||
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is false", async () => {
|
||||
const service = testBed.inject(DefaultSingleNudgeService);
|
||||
|
||||
await service.setNudgeStatus(
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
false,
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
{ hasBadgeDismissed: false, hasSpotlightDismissed: false },
|
||||
"user-id" as UserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaultNudgesService", () => {
|
||||
it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => {
|
||||
it("should return true, the proper value from the custom nudge service nudgeStatus$", async () => {
|
||||
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||
useValue: { shouldShowNudge$: () => of(true) },
|
||||
useValue: { nudgeStatus$: () => of(true) },
|
||||
});
|
||||
const service = testBed.inject(VaultNudgesService);
|
||||
|
||||
@@ -86,9 +96,9 @@ describe("Vault Nudges Service", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => {
|
||||
it("should return false, the proper value for the custom nudge service nudgeStatus$", async () => {
|
||||
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||
useValue: { shouldShowNudge$: () => of(false) },
|
||||
useValue: { nudgeStatus$: () => of(false) },
|
||||
});
|
||||
const service = testBed.inject(VaultNudgesService);
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { of, switchMap } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
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 { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
|
||||
|
||||
export type NudgeStatus = {
|
||||
hasBadgeDismissed: boolean;
|
||||
hasSpotlightDismissed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
|
||||
*/
|
||||
@@ -13,18 +21,17 @@ export enum VaultNudgeType {
|
||||
/** Nudge to show when user has no items in their vault
|
||||
* Add future nudges here
|
||||
*/
|
||||
EmptyVaultNudge = "empty-vault-nudge",
|
||||
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
|
||||
},
|
||||
);
|
||||
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
|
||||
Partial<Record<VaultNudgeType, NudgeStatus>>
|
||||
>(VAULT_NUDGES_DISK, "vaultNudgeDismissed", {
|
||||
deserializer: (nudge) => nudge,
|
||||
clearOn: [], // Do not clear dismissals
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@@ -37,6 +44,7 @@ export class VaultNudgesService {
|
||||
*/
|
||||
private customNudgeServices: any = {
|
||||
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
|
||||
[VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -45,6 +53,7 @@ export class VaultNudgesService {
|
||||
* @private
|
||||
*/
|
||||
private defaultNudgeService = inject(DefaultSingleNudgeService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
|
||||
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
|
||||
@@ -56,7 +65,14 @@ export class VaultNudgesService {
|
||||
* @param userId
|
||||
*/
|
||||
showNudge$(nudge: VaultNudgeType, userId: UserId) {
|
||||
return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId);
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
|
||||
}
|
||||
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +80,10 @@ export class VaultNudgesService {
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
dismissNudge(nudge: VaultNudgeType, userId: UserId) {
|
||||
return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId);
|
||||
async dismissNudge(nudge: VaultNudgeType, userId: UserId, onlyBadge: boolean = false) {
|
||||
const dismissedStatus = onlyBadge
|
||||
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
|
||||
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
|
||||
await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user