diff --git a/libs/vault/src/services/new-device-verification-notice.service.spec.ts b/libs/vault/src/services/new-device-verification-notice.service.spec.ts new file mode 100644 index 00000000000..9d68bf428e7 --- /dev/null +++ b/libs/vault/src/services/new-device-verification-notice.service.spec.ts @@ -0,0 +1,86 @@ +import { firstValueFrom, of } from "rxjs"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { + FakeAccountService, + FakeSingleUserState, + FakeStateProvider, + mockAccountServiceWith, +} from "../../../common/spec"; + +import { + NewDeviceVerificationNoticeService, + NewDeviceVerificationNotice, + NEW_DEVICE_VERIFICATION_NOTICE_KEY, +} from "./new-device-verification-notice.service"; + +describe("New Device Verification Notice", () => { + const sut = NEW_DEVICE_VERIFICATION_NOTICE_KEY; + const userId = Utils.newGuid() as UserId; + const mockUserId$ = of(userId); + let newDeviceVerificationService: NewDeviceVerificationNoticeService; + let mockNoticeState: FakeSingleUserState; + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + stateProvider = new FakeStateProvider(accountService); + mockNoticeState = stateProvider.singleUser.getFake(userId, NEW_DEVICE_VERIFICATION_NOTICE_KEY); + newDeviceVerificationService = new NewDeviceVerificationNoticeService(stateProvider); + }); + + it("should deserialize newDeviceVerificationNotice values", async () => { + const currentDate = new Date(); + const inputObj = { + last_dismissal: currentDate, + permanent_dismissal: false, + }; + + const expectedFolderData = { + last_dismissal: currentDate.toJSON(), + permanent_dismissal: false, + }; + + const result = sut.deserializer(JSON.parse(JSON.stringify(inputObj))); + + expect(result).toEqual(expectedFolderData); + }); + + describe("notice$", () => { + it("emits new device verification notice state", async () => { + const currentDate = new Date(); + const data = { + last_dismissal: currentDate, + permanent_dismissal: false, + }; + await stateProvider.setUserState(NEW_DEVICE_VERIFICATION_NOTICE_KEY, data, userId); + + const result = await firstValueFrom(newDeviceVerificationService.noticeState$(mockUserId$)); + + expect(result).toBe(data); + }); + }); + + describe("update notice state", () => { + it("should update the date with a new value", async () => { + const currentDate = new Date(); + const oldDate = new Date("11-11-2011"); + const oldState = { + last_dismissal: oldDate, + permanent_dismissal: false, + }; + const newState = { + last_dismissal: currentDate, + permanent_dismissal: true, + }; + mockNoticeState.nextState(oldState); + await newDeviceVerificationService.updateNewDeviceVerificationNoticeState(userId, newState); + + const result = await firstValueFrom(newDeviceVerificationService.noticeState$(mockUserId$)); + expect(result).toEqual(newState); + }); + }); +}); diff --git a/libs/vault/src/services/new-device-verification-notice.service.ts b/libs/vault/src/services/new-device-verification-notice.service.ts index be315db7c6c..596e610591d 100644 --- a/libs/vault/src/services/new-device-verification-notice.service.ts +++ b/libs/vault/src/services/new-device-verification-notice.service.ts @@ -1,41 +1,67 @@ import { Injectable } from "@angular/core"; -import { Observable } from "rxjs"; +import { Observable, switchMap, takeWhile } from "rxjs"; +import { Jsonify } from "type-fest"; import { - ActiveUserState, StateProvider, UserKeyDefinition, NEW_DEVICE_VERIFICATION_NOTICE, + SingleUserState, } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; -export type NewDeviceVerificationNotice = { - last_dismissal: string; +// This service checks when to show New Device Verification Notice to Users +// It will be a two phase approach and the values below will work with two different feature flags +// If a user dismisses the notice, use "last_dismissal" to wait 7 days before re-prompting +// permanent_dismissal will be checked if the user should never see the notice again +export class NewDeviceVerificationNotice { + last_dismissal: Date; permanent_dismissal: boolean; -}; -const NEW_DEVICE_VERIFICATION_NOTICE_KEY = new UserKeyDefinition( - NEW_DEVICE_VERIFICATION_NOTICE, - "noticeState", - { - deserializer: (jsonData) => jsonData, - clearOn: [], - }, -); + constructor(obj: Partial) { + if (obj == null) { + return; + } + this.last_dismissal = obj.last_dismissal || null; + this.permanent_dismissal = obj.permanent_dismissal || null; + } + + static fromJSON(obj: Jsonify) { + return Object.assign(new NewDeviceVerificationNotice({}), obj); + } +} + +export const NEW_DEVICE_VERIFICATION_NOTICE_KEY = + new UserKeyDefinition( + NEW_DEVICE_VERIFICATION_NOTICE, + "noticeState", + { + deserializer: (obj: Jsonify) => + NewDeviceVerificationNotice.fromJSON(obj), + clearOn: [], + }, + ); @Injectable() export class NewDeviceVerificationNoticeService { - private noticeState: ActiveUserState; - noticeState$: Observable; + constructor(private stateProvider: StateProvider) {} + + private noticeState(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, NEW_DEVICE_VERIFICATION_NOTICE_KEY); + } - constructor(private stateProvider: StateProvider) { - this.noticeState = this.stateProvider.getActive(NEW_DEVICE_VERIFICATION_NOTICE_KEY); - this.noticeState$ = this.noticeState.state$; + noticeState$(userId$: Observable): Observable { + return userId$.pipe( + takeWhile((userId) => userId != null), + switchMap((userId) => this.noticeState(userId).state$), + ); } async updateNewDeviceVerificationNoticeState( + userId: UserId, newState: NewDeviceVerificationNotice, ): Promise { - await this.noticeState.update(() => { + await this.noticeState(userId).update(() => { return { ...newState }; }); }