From e129e90faa143d9e1c0be1377893cbb02c34cefd Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:55:39 -0600 Subject: [PATCH] [PM-14347][PM-14348] New Device Verification Logic (#12451) * add account created date to the account information * set permanent dismissal flag when the user selects that they can access their email * update the logic of device verification notice * add service to cache the profile creation date to avoid calling the API multiple times * update step one logic for new device verification + add tests * update step two logic for new device verification + add tests - remove remind me later link for permanent logic * migrate 2FA check to use the profile property rather than hitting the API directly. The API for 2FA providers is only available on web so it didn't work for browser & native. * remove unneeded account related changes - profile creation is used from other sources * remove obsolete test * store the profile id within the vault service * remove unused map * store the associated profile id so account for profile switching in the extension * add comment for temporary service and ticket number to remove * formatting * move up logic for feature flags --- ...w-device-verification-notice.guard.spec.ts | 225 ++++++++++++++++++ .../new-device-verification-notice.guard.ts | 84 ++++++- .../services/vault-profile.service.spec.ts | 94 ++++++++ .../vault/services/vault-profile.service.ts | 64 +++++ ...fication-notice-page-one.component.spec.ts | 173 ++++++++++++++ ...-verification-notice-page-one.component.ts | 56 ++++- ...erification-notice-page-two.component.html | 6 +- ...fication-notice-page-two.component.spec.ts | 175 ++++++++++++++ ...-verification-notice-page-two.component.ts | 10 +- 9 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts create mode 100644 libs/angular/src/vault/services/vault-profile.service.spec.ts create mode 100644 libs/angular/src/vault/services/vault-profile.service.ts create mode 100644 libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts create mode 100644 libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts diff --git a/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts b/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts new file mode 100644 index 0000000000..e278113a65 --- /dev/null +++ b/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts @@ -0,0 +1,225 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service"; +import { VaultProfileService } from "../services/vault-profile.service"; + +import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard"; + +describe("NewDeviceVerificationNoticeGuard", () => { + const _state = Object.freeze({}) as RouterStateSnapshot; + const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot; + const eightDaysAgo = new Date(); + eightDaysAgo.setDate(eightDaysAgo.getDate() - 8); + + const account = { + id: "account-id", + } as unknown as Account; + + const activeAccount$ = new BehaviorSubject(account); + + const createUrlTree = jest.fn(); + const getFeatureFlag = jest.fn().mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + const isSelfHost = jest.fn().mockResolvedValue(false); + const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false); + const policyAppliesToActiveUser$ = jest.fn().mockReturnValue(new BehaviorSubject(false)); + const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null)); + const getProfileCreationDate = jest.fn().mockResolvedValue(eightDaysAgo); + + beforeEach(() => { + getFeatureFlag.mockClear(); + isSelfHost.mockClear(); + getProfileCreationDate.mockClear(); + getProfileTwoFactorEnabled.mockClear(); + policyAppliesToActiveUser$.mockClear(); + createUrlTree.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { createUrlTree } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + { provide: NewDeviceVerificationNoticeService, useValue: { noticeState$ } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: PlatformUtilsService, useValue: { isSelfHost } }, + { provide: PolicyService, useValue: { policyAppliesToActiveUser$ } }, + { + provide: VaultProfileService, + useValue: { getProfileCreationDate, getProfileTwoFactorEnabled }, + }, + ], + }); + }); + + function newDeviceGuard(route?: ActivatedRouteSnapshot) { + // Run the guard within injection context so `inject` works as you'd expect + // Pass state object to make TypeScript happy + return TestBed.runInInjectionContext(async () => + NewDeviceVerificationNoticeGuard(route ?? emptyRoute, _state), + ); + } + + describe("fromNewDeviceVerification", () => { + const route = { + queryParams: { fromNewDeviceVerification: "true" }, + } as unknown as ActivatedRouteSnapshot; + + it("returns `true` when `fromNewDeviceVerification` is present", async () => { + expect(await newDeviceGuard(route)).toBe(true); + }); + + it("does not execute other logic", async () => { + // `fromNewDeviceVerification` param should exit early, + // not foolproof but a quick way to test that other logic isn't executed + await newDeviceGuard(route); + + expect(getFeatureFlag).not.toHaveBeenCalled(); + expect(isSelfHost).not.toHaveBeenCalled(); + expect(getProfileTwoFactorEnabled).not.toHaveBeenCalled(); + expect(getProfileCreationDate).not.toHaveBeenCalled(); + expect(policyAppliesToActiveUser$).not.toHaveBeenCalled(); + }); + }); + + describe("missing current account", () => { + afterAll(() => { + // reset `activeAccount$` observable + activeAccount$.next(account); + }); + + it("redirects to login when account is missing", async () => { + activeAccount$.next(null); + + await newDeviceGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/login"]); + }); + }); + + it("returns `true` when 2FA is enabled", async () => { + getProfileTwoFactorEnabled.mockResolvedValueOnce(true); + + expect(await newDeviceGuard()).toBe(true); + }); + + it("returns `true` when the user is self hosted", async () => { + isSelfHost.mockReturnValueOnce(true); + + expect(await newDeviceGuard()).toBe(true); + }); + + it("returns `true` SSO is required", async () => { + policyAppliesToActiveUser$.mockReturnValueOnce(new BehaviorSubject(true)); + + expect(await newDeviceGuard()).toBe(true); + expect(policyAppliesToActiveUser$).toHaveBeenCalledWith(PolicyType.RequireSso); + }); + + it("returns `true` when the profile was created less than a week ago", async () => { + const sixDaysAgo = new Date(); + sixDaysAgo.setDate(sixDaysAgo.getDate() - 6); + + getProfileCreationDate.mockResolvedValueOnce(sixDaysAgo); + + expect(await newDeviceGuard()).toBe(true); + }); + + describe("temp flag", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + afterAll(() => { + getFeatureFlag.mockReturnValue(false); + }); + + it("redirects to notice when the user has not dismissed it", async () => { + noticeState$.mockReturnValueOnce(new BehaviorSubject(null)); + + await newDeviceGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]); + expect(noticeState$).toHaveBeenCalledWith(account.id); + }); + + it("redirects to notice when the user dismissed it more than 7 days ago", async () => { + const eighteenDaysAgo = new Date(); + eighteenDaysAgo.setDate(eighteenDaysAgo.getDate() - 18); + + noticeState$.mockReturnValueOnce( + new BehaviorSubject({ last_dismissal: eighteenDaysAgo.toISOString() }), + ); + + await newDeviceGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]); + }); + + it("returns true when the user dismissed less than 7 days ago", async () => { + const fourDaysAgo = new Date(); + fourDaysAgo.setDate(fourDaysAgo.getDate() - 4); + + noticeState$.mockReturnValueOnce( + new BehaviorSubject({ last_dismissal: fourDaysAgo.toISOString() }), + ); + + expect(await newDeviceGuard()).toBe(true); + }); + }); + + describe("permanent flag", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + afterAll(() => { + getFeatureFlag.mockReturnValue(false); + }); + + it("redirects when the user has not dismissed", async () => { + noticeState$.mockReturnValueOnce(new BehaviorSubject(null)); + + await newDeviceGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]); + + noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: null })); + + await newDeviceGuard(); + + expect(createUrlTree).toHaveBeenCalledTimes(2); + expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]); + }); + + it("returns `true` when the user has dismissed", async () => { + noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: true })); + + expect(await newDeviceGuard()).toBe(true); + }); + }); +}); diff --git a/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts b/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts index a37097e358..20550e0e8c 100644 --- a/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts +++ b/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts @@ -1,12 +1,16 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router"; -import { Observable, firstValueFrom, map } from "rxjs"; +import { Observable, firstValueFrom } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service"; +import { VaultProfileService } from "../services/vault-profile.service"; export const NewDeviceVerificationNoticeGuard: CanActivateFn = async ( route: ActivatedRouteSnapshot, @@ -15,6 +19,9 @@ export const NewDeviceVerificationNoticeGuard: CanActivateFn = async ( const configService = inject(ConfigService); const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService); const accountService = inject(AccountService); + const platformUtilsService = inject(PlatformUtilsService); + const policyService = inject(PolicyService); + const vaultProfileService = inject(VaultProfileService); if (route.queryParams["fromNewDeviceVerification"]) { return true; @@ -27,25 +34,88 @@ export const NewDeviceVerificationNoticeGuard: CanActivateFn = async ( FeatureFlag.NewDeviceVerificationPermanentDismiss, ); - const currentAcct$: Observable = accountService.activeAccount$.pipe( - map((acct) => acct), - ); + if (!tempNoticeFlag && !permNoticeFlag) { + return true; + } + + const currentAcct$: Observable = accountService.activeAccount$; const currentAcct = await firstValueFrom(currentAcct$); if (!currentAcct) { return router.createUrlTree(["/login"]); } + const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id); + const isSelfHosted = await platformUtilsService.isSelfHost(); + const requiresSSO = await isSSORequired(policyService); + const isProfileLessThanWeekOld = await profileIsLessThanWeekOld( + vaultProfileService, + currentAcct.id, + ); + + // When any of the following are true, the device verification notice is + // not applicable for the user. + if (has2FAEnabled || isSelfHosted || requiresSSO || isProfileLessThanWeekOld) { + return true; + } + const userItems$ = newDeviceVerificationNoticeService.noticeState$(currentAcct.id); const userItems = await firstValueFrom(userItems$); + // Show the notice when: + // - The temp notice flag is enabled + // - The user hasn't dismissed the notice or the user dismissed it more than 7 days ago if ( - userItems?.last_dismissal == null && - (userItems?.permanent_dismissal == null || !userItems?.permanent_dismissal) && - (tempNoticeFlag || permNoticeFlag) + tempNoticeFlag && + (!userItems?.last_dismissal || isMoreThan7DaysAgo(userItems?.last_dismissal)) ) { return router.createUrlTree(["/new-device-notice"]); } + // Show the notice when: + // - The permanent notice flag is enabled + // - The user hasn't dismissed the notice + if (permNoticeFlag && !userItems?.permanent_dismissal) { + return router.createUrlTree(["/new-device-notice"]); + } + return true; }; + +/** Returns true has one 2FA provider enabled */ +async function hasATwoFactorProviderEnabled( + vaultProfileService: VaultProfileService, + userId: string, +): Promise { + return vaultProfileService.getProfileTwoFactorEnabled(userId); +} + +/** Returns true when the user's profile is less than a week old */ +async function profileIsLessThanWeekOld( + vaultProfileService: VaultProfileService, + userId: string, +): Promise { + const creationDate = await vaultProfileService.getProfileCreationDate(userId); + return !isMoreThan7DaysAgo(creationDate); +} + +/** Returns true when the user is required to login via SSO */ +async function isSSORequired(policyService: PolicyService) { + return firstValueFrom(policyService.policyAppliesToActiveUser$(PolicyType.RequireSso)); +} + +/** Returns the true when the date given is older than 7 days */ +function isMoreThan7DaysAgo(date?: string | Date): boolean { + if (!date) { + return false; + } + + const inputDate = new Date(date).getTime(); + const today = new Date().getTime(); + + const differenceInMS = today - inputDate; + const msInADay = 1000 * 60 * 60 * 24; + const differenceInDays = Math.round(differenceInMS / msInADay); + + return differenceInDays > 7; +} diff --git a/libs/angular/src/vault/services/vault-profile.service.spec.ts b/libs/angular/src/vault/services/vault-profile.service.spec.ts new file mode 100644 index 0000000000..7761503253 --- /dev/null +++ b/libs/angular/src/vault/services/vault-profile.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from "@angular/core/testing"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { VaultProfileService } from "./vault-profile.service"; + +describe("VaultProfileService", () => { + let service: VaultProfileService; + const userId = "profile-id"; + const hardcodedDateString = "2024-02-24T12:00:00Z"; + + const getProfile = jest.fn().mockResolvedValue({ + creationDate: hardcodedDateString, + twoFactorEnabled: true, + id: "new-user-id", + }); + + beforeEach(() => { + getProfile.mockClear(); + + TestBed.configureTestingModule({ + providers: [{ provide: ApiService, useValue: { getProfile } }], + }); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-02-22T00:00:00Z")); + service = TestBed.runInInjectionContext(() => new VaultProfileService()); + service["userId"] = userId; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("getProfileCreationDate", () => { + it("calls `getProfile` when stored profile date is not set", async () => { + expect(service["profileCreatedDate"]).toBeNull(); + + const date = await service.getProfileCreationDate(userId); + + expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z"); + expect(getProfile).toHaveBeenCalled(); + }); + + it("calls `getProfile` when stored profile id does not match", async () => { + service["profileCreatedDate"] = hardcodedDateString; + service["userId"] = "old-user-id"; + + const date = await service.getProfileCreationDate(userId); + + expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z"); + expect(getProfile).toHaveBeenCalled(); + }); + + it("does not call `getProfile` when the date is already stored", async () => { + service["profileCreatedDate"] = hardcodedDateString; + + const date = await service.getProfileCreationDate(userId); + + expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z"); + expect(getProfile).not.toHaveBeenCalled(); + }); + }); + + describe("getProfileTwoFactorEnabled", () => { + it("calls `getProfile` when stored 2FA property is not stored", async () => { + expect(service["profile2FAEnabled"]).toBeNull(); + + const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId); + + expect(twoFactorEnabled).toBe(true); + expect(getProfile).toHaveBeenCalled(); + }); + + it("calls `getProfile` when stored profile id does not match", async () => { + service["profile2FAEnabled"] = false; + service["userId"] = "old-user-id"; + + const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId); + + expect(twoFactorEnabled).toBe(true); + expect(getProfile).toHaveBeenCalled(); + }); + + it("does not call `getProfile` when 2FA property is already stored", async () => { + service["profile2FAEnabled"] = false; + + const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId); + + expect(twoFactorEnabled).toBe(false); + expect(getProfile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/angular/src/vault/services/vault-profile.service.ts b/libs/angular/src/vault/services/vault-profile.service.ts new file mode 100644 index 0000000000..b368a97378 --- /dev/null +++ b/libs/angular/src/vault/services/vault-profile.service.ts @@ -0,0 +1,64 @@ +import { Injectable, inject } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; + +@Injectable({ + providedIn: "root", +}) +/** + * Class to provide profile level details without having to call the API each time. + * NOTE: This is a temporary service and can be replaced once the `UnauthenticatedExtensionUIRefresh` flag goes live. + * The `UnauthenticatedExtensionUIRefresh` introduces a sync that takes place upon logging in. These details can then + * be added to account object and retrieved from there. + * TODO: PM-16202 + */ +export class VaultProfileService { + private apiService = inject(ApiService); + + private userId: string | null = null; + + /** Profile creation stored as a string. */ + private profileCreatedDate: string | null = null; + + /** True when 2FA is enabled on the profile. */ + private profile2FAEnabled: boolean | null = null; + + /** + * Returns the creation date of the profile. + * Note: `Date`s are mutable in JS, creating a new + * instance is important to avoid unwanted changes. + */ + async getProfileCreationDate(userId: string): Promise { + if (this.profileCreatedDate && userId === this.userId) { + return Promise.resolve(new Date(this.profileCreatedDate)); + } + + const profile = await this.fetchAndCacheProfile(); + + return new Date(profile.creationDate); + } + + /** + * Returns whether there is a 2FA provider on the profile. + */ + async getProfileTwoFactorEnabled(userId: string): Promise { + if (this.profile2FAEnabled !== null && userId === this.userId) { + return Promise.resolve(this.profile2FAEnabled); + } + + const profile = await this.fetchAndCacheProfile(); + + return profile.twoFactorEnabled; + } + + private async fetchAndCacheProfile(): Promise { + const profile = await this.apiService.getProfile(); + + this.userId = profile.id; + this.profileCreatedDate = profile.creationDate; + this.profile2FAEnabled = profile.twoFactorEnabled; + + return profile; + } +} diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts new file mode 100644 index 0000000000..c6eccb7873 --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { NewDeviceVerificationNoticeService } from "../../services/new-device-verification-notice.service"; + +import { NewDeviceVerificationNoticePageOneComponent } from "./new-device-verification-notice-page-one.component"; + +describe("NewDeviceVerificationNoticePageOneComponent", () => { + let component: NewDeviceVerificationNoticePageOneComponent; + let fixture: ComponentFixture; + + const activeAccount$ = new BehaviorSubject({ email: "test@example.com", id: "acct-1" }); + const navigate = jest.fn().mockResolvedValue(null); + const updateNewDeviceVerificationNoticeState = jest.fn().mockResolvedValue(null); + const getFeatureFlag = jest.fn().mockResolvedValue(null); + + beforeEach(async () => { + navigate.mockClear(); + updateNewDeviceVerificationNoticeState.mockClear(); + getFeatureFlag.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: { t: (...key: string[]) => key.join(" ") } }, + { provide: Router, useValue: { navigate } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { + provide: NewDeviceVerificationNoticeService, + useValue: { updateNewDeviceVerificationNoticeState }, + }, + { provide: PlatformUtilsService, useValue: { getClientType: () => false } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewDeviceVerificationNoticePageOneComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("sets initial properties", () => { + expect(component["currentEmail"]).toBe("test@example.com"); + expect(component["currentUserId"]).toBe("acct-1"); + }); + + describe("temporary flag submission", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + describe("no email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(0); + fixture.detectChanges(); + + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + it("redirects to step two ", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["new-device-notice/setup"]); + }); + + it("does not update notice state", () => { + expect(getFeatureFlag).not.toHaveBeenCalled(); + expect(updateNewDeviceVerificationNoticeState).not.toHaveBeenCalled(); + }); + }); + + describe("has email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(1); + fixture.detectChanges(); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-03-03T00:00:00.000Z")); + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("redirects to the vault", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state with a new date", () => { + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-03-03T00:00:00.000Z"), + permanent_dismissal: false, + }); + }); + }); + }); + + describe("permanent flag submission", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + describe("no email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(0); + fixture.detectChanges(); + + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + it("redirects to step two", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["new-device-notice/setup"]); + }); + + it("does not update notice state", () => { + expect(getFeatureFlag).not.toHaveBeenCalled(); + expect(updateNewDeviceVerificationNoticeState).not.toHaveBeenCalled(); + }); + }); + + describe("has email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(1); + fixture.detectChanges(); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-04-04T00:00:00.000Z")); + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("redirects to the vault ", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state with a new date", () => { + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-04-04T00:00:00.000Z"), + permanent_dismissal: true, + }); + }); + }); + }); +}); diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts index 62ae22f5b2..70ac4073a0 100644 --- a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts @@ -7,6 +7,8 @@ import { firstValueFrom, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -18,7 +20,10 @@ import { TypographyModule, } from "@bitwarden/components"; -import { NewDeviceVerificationNoticeService } from "./../../services/new-device-verification-notice.service"; +import { + NewDeviceVerificationNotice, + NewDeviceVerificationNoticeService, +} from "./../../services/new-device-verification-notice.service"; @Component({ standalone: true, @@ -51,6 +56,7 @@ export class NewDeviceVerificationNoticePageOneComponent implements OnInit { private accountService: AccountService, private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService, private platformUtilsService: PlatformUtilsService, + private configService: ConfigService, ) { this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop; } @@ -65,18 +71,44 @@ export class NewDeviceVerificationNoticePageOneComponent implements OnInit { } submit = async () => { - if (this.formGroup.controls.hasEmailAccess.value === 0) { - await this.router.navigate(["new-device-notice/setup"]); - } else if (this.formGroup.controls.hasEmailAccess.value === 1) { - await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState( - this.currentUserId, - { - last_dismissal: new Date(), - permanent_dismissal: false, - }, - ); + const doesNotHaveEmailAccess = this.formGroup.controls.hasEmailAccess.value === 0; - await this.router.navigate(["/vault"]); + if (doesNotHaveEmailAccess) { + await this.router.navigate(["new-device-notice/setup"]); + return; } + + const tempNoticeFlag = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationTemporaryDismiss, + ); + const permNoticeFlag = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationPermanentDismiss, + ); + + let newNoticeState: NewDeviceVerificationNotice | null = null; + + // When the temporary flag is enabled, only update the `last_dismissal` + if (tempNoticeFlag) { + newNoticeState = { + last_dismissal: new Date(), + permanent_dismissal: false, + }; + } else if (permNoticeFlag) { + // When the per flag is enabled, only update the `last_dismissal` + newNoticeState = { + last_dismissal: new Date(), + permanent_dismissal: true, + }; + } + + // This shouldn't occur as the user shouldn't get here unless one of the flags is active. + if (newNoticeState) { + await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState( + this.currentUserId!, + newNoticeState, + ); + } + + await this.router.navigate(["/vault"]); }; } diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html index 270b412625..66a61f3b8d 100644 --- a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html @@ -8,6 +8,7 @@ (click)="navigateToTwoStepLogin($event)" buttonType="primary" class="tw-w-full tw-mt-4" + data-testid="two-factor" > {{ "turnOnTwoStepLogin" | i18n }} {{ "changeAcctEmail" | i18n }} -
- + diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts new file mode 100644 index 0000000000..92f0494776 --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts @@ -0,0 +1,175 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { NewDeviceVerificationNoticeService } from "../../services/new-device-verification-notice.service"; + +import { NewDeviceVerificationNoticePageTwoComponent } from "./new-device-verification-notice-page-two.component"; + +describe("NewDeviceVerificationNoticePageTwoComponent", () => { + let component: NewDeviceVerificationNoticePageTwoComponent; + let fixture: ComponentFixture; + + const activeAccount$ = new BehaviorSubject({ email: "test@example.com", id: "acct-1" }); + const environment$ = new BehaviorSubject({ getWebVaultUrl: () => "vault.bitwarden.com" }); + const navigate = jest.fn().mockResolvedValue(null); + const updateNewDeviceVerificationNoticeState = jest.fn().mockResolvedValue(null); + const getFeatureFlag = jest.fn().mockResolvedValue(false); + const getClientType = jest.fn().mockReturnValue(ClientType.Browser); + const launchUri = jest.fn(); + + beforeEach(async () => { + navigate.mockClear(); + updateNewDeviceVerificationNoticeState.mockClear(); + getFeatureFlag.mockClear(); + getClientType.mockClear(); + launchUri.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: { t: (...key: string[]) => key.join(" ") } }, + { provide: Router, useValue: { navigate } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: EnvironmentService, useValue: { environment$ } }, + { + provide: NewDeviceVerificationNoticeService, + useValue: { updateNewDeviceVerificationNoticeState }, + }, + { provide: PlatformUtilsService, useValue: { getClientType, launchUri } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewDeviceVerificationNoticePageTwoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("sets initial properties", () => { + expect(component["currentUserId"]).toBe("acct-1"); + expect(component["permanentFlagEnabled"]).toBe(false); + }); + + describe("change email", () => { + const changeEmailButton = () => + fixture.debugElement.query(By.css('[data-testid="change-email"]')); + + describe("web", () => { + beforeEach(() => { + component["isWeb"] = true; + fixture.detectChanges(); + }); + + it("navigates to settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/settings/account"], { + queryParams: { fromNewDeviceVerification: true }, + }); + expect(launchUri).not.toHaveBeenCalled(); + }); + }); + + describe("browser/desktop", () => { + beforeEach(() => { + component["isWeb"] = false; + fixture.detectChanges(); + }); + + it("launches to settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).not.toHaveBeenCalled(); + expect(launchUri).toHaveBeenCalledWith( + "vault.bitwarden.com/#/settings/account/?fromNewDeviceVerification=true", + ); + }); + }); + }); + + describe("enable 2fa", () => { + const changeEmailButton = () => + fixture.debugElement.query(By.css('[data-testid="two-factor"]')); + + describe("web", () => { + beforeEach(() => { + component["isWeb"] = true; + fixture.detectChanges(); + }); + + it("navigates to two factor settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/settings/security/two-factor"], { + queryParams: { fromNewDeviceVerification: true }, + }); + expect(launchUri).not.toHaveBeenCalled(); + }); + }); + + describe("browser/desktop", () => { + beforeEach(() => { + component["isWeb"] = false; + fixture.detectChanges(); + }); + + it("launches to two factor settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).not.toHaveBeenCalled(); + expect(launchUri).toHaveBeenCalledWith( + "vault.bitwarden.com/#/settings/security/two-factor/?fromNewDeviceVerification=true", + ); + }); + }); + }); + + describe("remind me later", () => { + const remindMeLater = () => + fixture.debugElement.query(By.css('[data-testid="remind-me-later"]')); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-02-02T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("navigates to the vault", () => { + remindMeLater().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state", () => { + remindMeLater().nativeElement.click(); + + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledTimes(1); + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-02-02T00:00:00.000Z"), + permanent_dismissal: false, + }); + }); + + it("is hidden when the permanent flag is enabled", async () => { + getFeatureFlag.mockResolvedValueOnce(true); + await component.ngOnInit(); + fixture.detectChanges(); + + expect(remindMeLater()).toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts index 630a2fd516..b3634dcc28 100644 --- a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts @@ -6,6 +6,8 @@ import { firstValueFrom, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -25,6 +27,7 @@ import { NewDeviceVerificationNoticeService } from "../../services/new-device-ve export class NewDeviceVerificationNoticePageTwoComponent implements OnInit { protected isWeb: boolean; protected isDesktop: boolean; + protected permanentFlagEnabled = false; readonly currentAcct$: Observable = this.accountService.activeAccount$; private currentUserId: UserId | null = null; private env$: Observable = this.environmentService.environment$; @@ -35,12 +38,17 @@ export class NewDeviceVerificationNoticePageTwoComponent implements OnInit { private accountService: AccountService, private platformUtilsService: PlatformUtilsService, private environmentService: EnvironmentService, + private configService: ConfigService, ) { this.isWeb = this.platformUtilsService.getClientType() === ClientType.Web; this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop; } async ngOnInit() { + this.permanentFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationPermanentDismiss, + ); + const currentAcct = await firstValueFrom(this.currentAcct$); if (!currentAcct) { return; @@ -83,7 +91,7 @@ export class NewDeviceVerificationNoticePageTwoComponent implements OnInit { async remindMeLaterSelect() { await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState( - this.currentUserId, + this.currentUserId!, { last_dismissal: new Date(), permanent_dismissal: false,