mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[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
This commit is contained in:
@@ -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 | null>(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<boolean>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { inject } from "@angular/core";
|
import { inject } from "@angular/core";
|
||||||
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
|
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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
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 { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
|
||||||
|
import { VaultProfileService } from "../services/vault-profile.service";
|
||||||
|
|
||||||
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
|
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
|
||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
@@ -15,6 +19,9 @@ export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
|
|||||||
const configService = inject(ConfigService);
|
const configService = inject(ConfigService);
|
||||||
const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService);
|
const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService);
|
||||||
const accountService = inject(AccountService);
|
const accountService = inject(AccountService);
|
||||||
|
const platformUtilsService = inject(PlatformUtilsService);
|
||||||
|
const policyService = inject(PolicyService);
|
||||||
|
const vaultProfileService = inject(VaultProfileService);
|
||||||
|
|
||||||
if (route.queryParams["fromNewDeviceVerification"]) {
|
if (route.queryParams["fromNewDeviceVerification"]) {
|
||||||
return true;
|
return true;
|
||||||
@@ -27,25 +34,88 @@ export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
|
|||||||
FeatureFlag.NewDeviceVerificationPermanentDismiss,
|
FeatureFlag.NewDeviceVerificationPermanentDismiss,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentAcct$: Observable<Account | null> = accountService.activeAccount$.pipe(
|
if (!tempNoticeFlag && !permNoticeFlag) {
|
||||||
map((acct) => acct),
|
return true;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const currentAcct$: Observable<Account | null> = accountService.activeAccount$;
|
||||||
const currentAcct = await firstValueFrom(currentAcct$);
|
const currentAcct = await firstValueFrom(currentAcct$);
|
||||||
|
|
||||||
if (!currentAcct) {
|
if (!currentAcct) {
|
||||||
return router.createUrlTree(["/login"]);
|
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$ = newDeviceVerificationNoticeService.noticeState$(currentAcct.id);
|
||||||
const userItems = await firstValueFrom(userItems$);
|
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 (
|
if (
|
||||||
userItems?.last_dismissal == null &&
|
tempNoticeFlag &&
|
||||||
(userItems?.permanent_dismissal == null || !userItems?.permanent_dismissal) &&
|
(!userItems?.last_dismissal || isMoreThan7DaysAgo(userItems?.last_dismissal))
|
||||||
(tempNoticeFlag || permNoticeFlag)
|
|
||||||
) {
|
) {
|
||||||
return router.createUrlTree(["/new-device-notice"]);
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Returns true has one 2FA provider enabled */
|
||||||
|
async function hasATwoFactorProviderEnabled(
|
||||||
|
vaultProfileService: VaultProfileService,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
64
libs/angular/src/vault/services/vault-profile.service.ts
Normal file
64
libs/angular/src/vault/services/vault-profile.service.ts
Normal file
@@ -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<Date> {
|
||||||
|
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<boolean> {
|
||||||
|
if (this.profile2FAEnabled !== null && userId === this.userId) {
|
||||||
|
return Promise.resolve(this.profile2FAEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.fetchAndCacheProfile();
|
||||||
|
|
||||||
|
return profile.twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAndCacheProfile(): Promise<ProfileResponse> {
|
||||||
|
const profile = await this.apiService.getProfile();
|
||||||
|
|
||||||
|
this.userId = profile.id;
|
||||||
|
this.profileCreatedDate = profile.creationDate;
|
||||||
|
this.profile2FAEnabled = profile.twoFactorEnabled;
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NewDeviceVerificationNoticePageOneComponent>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ import { firstValueFrom, Observable } from "rxjs";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { ClientType } from "@bitwarden/common/enums";
|
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +20,10 @@ import {
|
|||||||
TypographyModule,
|
TypographyModule,
|
||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { NewDeviceVerificationNoticeService } from "./../../services/new-device-verification-notice.service";
|
import {
|
||||||
|
NewDeviceVerificationNotice,
|
||||||
|
NewDeviceVerificationNoticeService,
|
||||||
|
} from "./../../services/new-device-verification-notice.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -51,6 +56,7 @@ export class NewDeviceVerificationNoticePageOneComponent implements OnInit {
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService,
|
private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop;
|
this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop;
|
||||||
}
|
}
|
||||||
@@ -65,18 +71,44 @@ export class NewDeviceVerificationNoticePageOneComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit = async () => {
|
submit = async () => {
|
||||||
if (this.formGroup.controls.hasEmailAccess.value === 0) {
|
const doesNotHaveEmailAccess = 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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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"]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
(click)="navigateToTwoStepLogin($event)"
|
(click)="navigateToTwoStepLogin($event)"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
class="tw-w-full tw-mt-4"
|
class="tw-w-full tw-mt-4"
|
||||||
|
data-testid="two-factor"
|
||||||
>
|
>
|
||||||
{{ "turnOnTwoStepLogin" | i18n }}
|
{{ "turnOnTwoStepLogin" | i18n }}
|
||||||
<i
|
<i
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
(click)="navigateToChangeAcctEmail($event)"
|
(click)="navigateToChangeAcctEmail($event)"
|
||||||
buttonType="secondary"
|
buttonType="secondary"
|
||||||
class="tw-w-full tw-mt-4"
|
class="tw-w-full tw-mt-4"
|
||||||
|
data-testid="change-email"
|
||||||
>
|
>
|
||||||
{{ "changeAcctEmail" | i18n }}
|
{{ "changeAcctEmail" | i18n }}
|
||||||
<i
|
<i
|
||||||
@@ -32,8 +34,8 @@
|
|||||||
></i>
|
></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="tw-flex tw-justify-center tw-mt-6">
|
<div class="tw-flex tw-justify-center tw-mt-6" *ngIf="!permanentFlagEnabled">
|
||||||
<a bitLink linkType="primary" (click)="remindMeLaterSelect()">
|
<a bitLink linkType="primary" (click)="remindMeLaterSelect()" data-testid="remind-me-later">
|
||||||
{{ "remindMeLater" | i18n }}
|
{{ "remindMeLater" | i18n }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<NewDeviceVerificationNoticePageTwoComponent>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ import { firstValueFrom, Observable } from "rxjs";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { ClientType } from "@bitwarden/common/enums";
|
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 {
|
import {
|
||||||
Environment,
|
Environment,
|
||||||
EnvironmentService,
|
EnvironmentService,
|
||||||
@@ -25,6 +27,7 @@ import { NewDeviceVerificationNoticeService } from "../../services/new-device-ve
|
|||||||
export class NewDeviceVerificationNoticePageTwoComponent implements OnInit {
|
export class NewDeviceVerificationNoticePageTwoComponent implements OnInit {
|
||||||
protected isWeb: boolean;
|
protected isWeb: boolean;
|
||||||
protected isDesktop: boolean;
|
protected isDesktop: boolean;
|
||||||
|
protected permanentFlagEnabled = false;
|
||||||
readonly currentAcct$: Observable<Account | null> = this.accountService.activeAccount$;
|
readonly currentAcct$: Observable<Account | null> = this.accountService.activeAccount$;
|
||||||
private currentUserId: UserId | null = null;
|
private currentUserId: UserId | null = null;
|
||||||
private env$: Observable<Environment> = this.environmentService.environment$;
|
private env$: Observable<Environment> = this.environmentService.environment$;
|
||||||
@@ -35,12 +38,17 @@ export class NewDeviceVerificationNoticePageTwoComponent implements OnInit {
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.isWeb = this.platformUtilsService.getClientType() === ClientType.Web;
|
this.isWeb = this.platformUtilsService.getClientType() === ClientType.Web;
|
||||||
this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop;
|
this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
|
this.permanentFlagEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.NewDeviceVerificationPermanentDismiss,
|
||||||
|
);
|
||||||
|
|
||||||
const currentAcct = await firstValueFrom(this.currentAcct$);
|
const currentAcct = await firstValueFrom(this.currentAcct$);
|
||||||
if (!currentAcct) {
|
if (!currentAcct) {
|
||||||
return;
|
return;
|
||||||
@@ -83,7 +91,7 @@ export class NewDeviceVerificationNoticePageTwoComponent implements OnInit {
|
|||||||
|
|
||||||
async remindMeLaterSelect() {
|
async remindMeLaterSelect() {
|
||||||
await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState(
|
await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState(
|
||||||
this.currentUserId,
|
this.currentUserId!,
|
||||||
{
|
{
|
||||||
last_dismissal: new Date(),
|
last_dismissal: new Date(),
|
||||||
permanent_dismissal: false,
|
permanent_dismissal: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user