mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 13:40:06 +00:00
Refactor TwoFactorFormCacheService
This commit is contained in:
@@ -1,207 +0,0 @@
|
||||
import { signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { TwoFactorFormData } from "@bitwarden/auth/angular";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { ExtensionTwoFactorFormCacheService } from "./extension-two-factor-form-cache.service";
|
||||
|
||||
describe("ExtensionTwoFactorFormCacheService", () => {
|
||||
let service: ExtensionTwoFactorFormCacheService;
|
||||
let testBed: TestBed;
|
||||
const formDataSignal = signal<TwoFactorFormData | null>(null);
|
||||
const getFormDataSignal = jest.fn().mockReturnValue(formDataSignal);
|
||||
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||
const formDataSetMock = jest.spyOn(formDataSignal, "set");
|
||||
|
||||
const mockFormData: TwoFactorFormData = {
|
||||
token: "123456",
|
||||
remember: true,
|
||||
selectedProviderType: TwoFactorProviderType.Authenticator,
|
||||
emailSent: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getFormDataSignal.mockClear();
|
||||
getFeatureFlag.mockClear();
|
||||
formDataSetMock.mockClear();
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ViewCacheService, useValue: { signal: getFormDataSignal } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
ExtensionTwoFactorFormCacheService,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature enabled", () => {
|
||||
beforeEach(async () => {
|
||||
getFeatureFlag.mockImplementation((featureFlag: FeatureFlag) => {
|
||||
if (featureFlag === FeatureFlag.PM9115_TwoFactorExtensionDataPersistence) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
|
||||
service = testBed.inject(ExtensionTwoFactorFormCacheService);
|
||||
});
|
||||
|
||||
describe("isEnabled$", () => {
|
||||
it("emits true when feature flag is on", async () => {
|
||||
const result = await firstValueFrom(service.isEnabled$());
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEnabled", () => {
|
||||
it("returns true when feature flag is on", async () => {
|
||||
const result = await service.isEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormData", () => {
|
||||
it("returns cached form data", async () => {
|
||||
formDataSignal.set(mockFormData);
|
||||
|
||||
const result = await service.getFormData();
|
||||
|
||||
expect(result).toEqual(mockFormData);
|
||||
});
|
||||
|
||||
it("returns null when cache is empty", async () => {
|
||||
formDataSignal.set(null);
|
||||
|
||||
const result = await service.getFormData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formData$", () => {
|
||||
it("emits cached form data", async () => {
|
||||
formDataSignal.set(mockFormData);
|
||||
|
||||
const result = await firstValueFrom(service.formData$());
|
||||
|
||||
expect(result).toEqual(mockFormData);
|
||||
});
|
||||
|
||||
it("emits null when cache is empty", async () => {
|
||||
formDataSignal.set(null);
|
||||
|
||||
const result = await firstValueFrom(service.formData$());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveFormData", () => {
|
||||
it("updates the cached form data", async () => {
|
||||
await service.saveFormData(mockFormData);
|
||||
|
||||
expect(formDataSetMock).toHaveBeenCalledWith({ ...mockFormData });
|
||||
});
|
||||
|
||||
it("creates a shallow copy of the data", async () => {
|
||||
const data = { ...mockFormData };
|
||||
|
||||
await service.saveFormData(data);
|
||||
|
||||
expect(formDataSetMock).toHaveBeenCalledWith(data);
|
||||
// Should be a new object, not the same reference
|
||||
expect(formDataSetMock.mock.calls[0][0]).not.toBe(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearFormData", () => {
|
||||
it("sets the cache to null", async () => {
|
||||
await service.clearFormData();
|
||||
|
||||
expect(formDataSetMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature disabled", () => {
|
||||
beforeEach(async () => {
|
||||
formDataSignal.set(mockFormData);
|
||||
getFeatureFlag.mockImplementation((featureFlag: FeatureFlag) => {
|
||||
if (featureFlag === FeatureFlag.PM9115_TwoFactorExtensionDataPersistence) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
|
||||
service = testBed.inject(ExtensionTwoFactorFormCacheService);
|
||||
formDataSetMock.mockClear();
|
||||
});
|
||||
|
||||
describe("isEnabled$", () => {
|
||||
it("emits false when feature flag is off", async () => {
|
||||
const result = await firstValueFrom(service.isEnabled$());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEnabled", () => {
|
||||
it("returns false when feature flag is off", async () => {
|
||||
const result = await service.isEnabled();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formData$", () => {
|
||||
it("emits null when feature is disabled", async () => {
|
||||
const result = await firstValueFrom(service.formData$());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormData", () => {
|
||||
it("returns null when feature is disabled", async () => {
|
||||
const result = await service.getFormData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveFormData", () => {
|
||||
it("does not update cache when feature is disabled", async () => {
|
||||
await service.saveFormData(mockFormData);
|
||||
|
||||
expect(formDataSetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearFormData", () => {
|
||||
it("still works when feature is disabled", async () => {
|
||||
await service.clearFormData();
|
||||
|
||||
expect(formDataSetMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Injectable, WritableSignal } from "@angular/core";
|
||||
import { Observable, of, switchMap, from } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
const TWO_FACTOR_FORM_CACHE_KEY = "two-factor-form-cache";
|
||||
|
||||
// Utilize function overloading to create a type-safe deserializer to match the exact expected signature
|
||||
function deserializeFormData(jsonValue: null): null;
|
||||
function deserializeFormData(jsonValue: {
|
||||
token?: string;
|
||||
remember?: boolean;
|
||||
selectedProviderType?: TwoFactorProviderType;
|
||||
emailSent?: boolean;
|
||||
}): TwoFactorFormData;
|
||||
function deserializeFormData(jsonValue: any): TwoFactorFormData | null {
|
||||
if (!jsonValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: jsonValue.token,
|
||||
remember: jsonValue.remember,
|
||||
selectedProviderType: jsonValue.selectedProviderType,
|
||||
emailSent: jsonValue.emailSent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for caching two-factor form data
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExtensionTwoFactorFormCacheService extends TwoFactorFormCacheService {
|
||||
private formDataCache: WritableSignal<TwoFactorFormData | null>;
|
||||
|
||||
constructor(
|
||||
private viewCacheService: ViewCacheService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super();
|
||||
this.formDataCache = this.viewCacheService.signal<TwoFactorFormData | null>({
|
||||
key: TWO_FACTOR_FORM_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: deserializeFormData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable that emits the current enabled state
|
||||
*/
|
||||
isEnabled$(): Observable<boolean> {
|
||||
return from(
|
||||
this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorExtensionDataPersistence),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable that emits the current form data
|
||||
*/
|
||||
formData$(): Observable<TwoFactorFormData | null> {
|
||||
return this.isEnabled$().pipe(
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return of(null);
|
||||
}
|
||||
return of(this.formDataCache());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save form data to cache
|
||||
*/
|
||||
async saveFormData(data: TwoFactorFormData): Promise<void> {
|
||||
if (!(await this.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the new form data in the cache
|
||||
this.formDataCache.set({ ...data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear form data from cache
|
||||
*/
|
||||
async clearFormData(): Promise<void> {
|
||||
this.formDataCache.set(null);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
TwoFactorAuthDuoComponentService,
|
||||
TwoFactorAuthWebAuthnComponentService,
|
||||
SsoComponentService,
|
||||
TwoFactorFormCacheService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
LockService,
|
||||
@@ -147,7 +146,6 @@ import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/exte
|
||||
import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service";
|
||||
import { ExtensionTwoFactorAuthEmailComponentService } from "../../auth/services/extension-two-factor-auth-email-component.service";
|
||||
import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service";
|
||||
import { ExtensionTwoFactorFormCacheService } from "../../auth/services/extension-two-factor-form-cache.service";
|
||||
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
|
||||
import AutofillService from "../../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
|
||||
@@ -566,11 +564,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: ExtensionTwoFactorAuthWebAuthnComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorFormCacheService,
|
||||
useClass: ExtensionTwoFactorFormCacheService,
|
||||
deps: [PopupViewCacheService, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorAuthDuoComponentService,
|
||||
useClass: ExtensionTwoFactorAuthDuoComponentService,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
SsoComponentService,
|
||||
DefaultSsoComponentService,
|
||||
TwoFactorAuthDuoComponentService,
|
||||
TwoFactorFormCacheService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
@@ -110,7 +109,6 @@ import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarde
|
||||
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
|
||||
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
|
||||
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
|
||||
import { DesktopTwoFactorFormCacheService } from "../../auth/services/desktop-two-factor-form-cache.service";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
@@ -423,11 +421,6 @@ const safeProviders: SafeProvider[] = [
|
||||
PlatformUtilsServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorFormCacheService,
|
||||
useClass: DesktopTwoFactorFormCacheService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SdkClientFactory,
|
||||
useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
|
||||
|
||||
/**
|
||||
* No-op implementation of TwoFactorFormCacheService for desktop
|
||||
*/
|
||||
@Injectable()
|
||||
export class DesktopTwoFactorFormCacheService extends TwoFactorFormCacheService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
isEnabled$(): Observable<boolean> {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
formData$(): Observable<TwoFactorFormData | null> {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
async saveFormData(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async clearFormData(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./web-two-factor-auth-component.service";
|
||||
export * from "./web-two-factor-auth-duo-component.service";
|
||||
export * from "./web-two-factor-form-cache.service";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
|
||||
|
||||
/**
|
||||
* No-op implementation of TwoFactorFormCacheService for web app
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebTwoFactorFormCacheService extends TwoFactorFormCacheService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
isEnabled$(): Observable<boolean> {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
formData$(): Observable<TwoFactorFormData | null> {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
async saveFormData(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async clearFormData(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
LoginDecryptionOptionsService,
|
||||
TwoFactorAuthComponentService,
|
||||
TwoFactorAuthDuoComponentService,
|
||||
TwoFactorFormCacheService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
@@ -120,7 +119,6 @@ import {
|
||||
LinkSsoService,
|
||||
} from "../auth";
|
||||
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
|
||||
import { WebTwoFactorFormCacheService } from "../auth/core/services/two-factor-auth/web-two-factor-form-cache.service";
|
||||
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
|
||||
import { HtmlStorageService } from "../core/html-storage.service";
|
||||
import { I18nService } from "../core/i18n.service";
|
||||
@@ -279,11 +277,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: WebTwoFactorAuthComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorFormCacheService,
|
||||
useClass: WebTwoFactorFormCacheService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SetPasswordJitService,
|
||||
useClass: WebSetPasswordJitService,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./two-factor-form-cache.service.abstraction";
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
|
||||
/**
|
||||
* Interface for two-factor form data
|
||||
*/
|
||||
export interface TwoFactorFormData {
|
||||
token?: string;
|
||||
remember?: boolean;
|
||||
selectedProviderType?: TwoFactorProviderType;
|
||||
emailSent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract service for two-factor form caching
|
||||
*/
|
||||
export abstract class TwoFactorFormCacheService {
|
||||
/**
|
||||
* Observable that emits the current enabled state of the feature flag
|
||||
*/
|
||||
abstract isEnabled$(): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Helper method that returns whether the feature is enabled
|
||||
* @returns A promise that resolves to true if the feature is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return firstValueFrom(this.isEnabled$());
|
||||
}
|
||||
|
||||
/**
|
||||
* Save form data to cache
|
||||
*/
|
||||
abstract saveFormData(data: TwoFactorFormData): Promise<void>;
|
||||
|
||||
/**
|
||||
* Observable that emits the current form data
|
||||
*/
|
||||
abstract formData$(): Observable<TwoFactorFormData | null>;
|
||||
|
||||
/**
|
||||
* Helper method to retrieve form data
|
||||
* @returns A promise that resolves to the form data
|
||||
*/
|
||||
async getFormData(): Promise<TwoFactorFormData | null> {
|
||||
return firstValueFrom(this.formData$());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear form data from cache
|
||||
*/
|
||||
abstract clearFormData(): Promise<void>;
|
||||
}
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { TwoFactorFormCacheService } from "../../abstractions/two-factor-form-cache.service.abstraction";
|
||||
|
||||
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
|
||||
|
||||
@Component({
|
||||
@@ -45,7 +43,9 @@ import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-comp
|
||||
})
|
||||
export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
|
||||
@Input({ required: true }) emailSent: boolean = false;
|
||||
@Output() tokenChange = new EventEmitter<{ token: string }>();
|
||||
@Output() emailSendEvent = new EventEmitter<void>();
|
||||
|
||||
twoFactorEmail: string | undefined = undefined;
|
||||
emailPromise: Promise<any> | undefined;
|
||||
@@ -60,7 +60,6 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
protected appIdService: AppIdService,
|
||||
private toastService: ToastService,
|
||||
private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService,
|
||||
private twoFactorFormCacheService: TwoFactorFormCacheService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -80,20 +79,15 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
|
||||
this.twoFactorEmail = email2faProviderData.Email;
|
||||
|
||||
// Check if email has already been sent according to the cache
|
||||
let emailAlreadySent = false;
|
||||
try {
|
||||
const cachedData = await this.twoFactorFormCacheService.getFormData();
|
||||
emailAlreadySent = cachedData?.emailSent === true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
if (!emailAlreadySent) {
|
||||
if (!this.emailSent) {
|
||||
await this.sendEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the token value to the parent component
|
||||
* @param event - The event object from the input field
|
||||
*/
|
||||
onTokenChange(event: Event) {
|
||||
const tokenValue = (event.target as HTMLInputElement).value || "";
|
||||
this.tokenChange.emit({ token: tokenValue });
|
||||
@@ -130,17 +124,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
this.emailPromise = this.apiService.postTwoFactorEmail(request);
|
||||
await this.emailPromise;
|
||||
|
||||
// Update cache to indicate email was sent
|
||||
try {
|
||||
const cachedData = (await this.twoFactorFormCacheService.getFormData()) || {};
|
||||
await this.twoFactorFormCacheService.saveFormData({
|
||||
...cachedData,
|
||||
emailSent: true,
|
||||
token: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
this.emailSendEvent.emit();
|
||||
|
||||
if (doToast) {
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -2,6 +2,5 @@ export * from "./two-factor-auth-component.service";
|
||||
export * from "./default-two-factor-auth-component.service";
|
||||
export * from "./two-factor-auth.component";
|
||||
export * from "./two-factor-auth.guard";
|
||||
export * from "./abstractions";
|
||||
|
||||
export * from "./child-components";
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<app-two-factor-auth-email
|
||||
[tokenFormControl]="tokenFormControl"
|
||||
(tokenChange)="saveFormDataWithPartialData($event)"
|
||||
[emailSent]="emailSent"
|
||||
(emailSendEvent)="saveFormDataWithPartialData({ emailSent: true })"
|
||||
*ngIf="selectedProviderType === providerType.Email"
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DefaultTwoFactorFormCacheService } from "../../common/services/auth-request/default-two-factor-form-cache.service";
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
import {
|
||||
TwoFactorAuthAuthenticatorIcon,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
TwoFactorAuthDuoIcon,
|
||||
} from "../icons/two-factor-auth";
|
||||
|
||||
import { TwoFactorFormCacheService } from "./abstractions";
|
||||
import { TwoFactorAuthAuthenticatorComponent } from "./child-components/two-factor-auth-authenticator.component";
|
||||
import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-duo/two-factor-auth-duo.component";
|
||||
import { TwoFactorAuthEmailComponent } from "./child-components/two-factor-auth-email/two-factor-auth-email.component";
|
||||
@@ -101,7 +101,11 @@ interface TwoFactorFormCacheData {
|
||||
TwoFactorAuthYubikeyComponent,
|
||||
TwoFactorAuthWebAuthnComponent,
|
||||
],
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: DefaultTwoFactorFormCacheService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("continueButton", { read: ElementRef, static: false }) continueButton:
|
||||
@@ -110,6 +114,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* Whether the email has been sent according to the cache
|
||||
*/
|
||||
emailSent = false;
|
||||
|
||||
orgSsoIdentifier: string | undefined = undefined;
|
||||
|
||||
providerType = TwoFactorProviderType;
|
||||
@@ -171,7 +180,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private environmentService: EnvironmentService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private twoFactorFormCacheService: TwoFactorFormCacheService,
|
||||
private twoFactorFormCacheService: DefaultTwoFactorFormCacheService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -180,9 +189,12 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.listenForAuthnSessionTimeout();
|
||||
|
||||
// Initialize the cache
|
||||
await this.twoFactorFormCacheService.init();
|
||||
|
||||
// Load persisted form data if available
|
||||
let loadedCachedProviderType = false;
|
||||
const persistedData = await this.twoFactorFormCacheService.getFormData();
|
||||
const persistedData = this.twoFactorFormCacheService.getCachedTwoFactorFormData();
|
||||
if (persistedData) {
|
||||
if (persistedData.token) {
|
||||
this.form.patchValue({ token: persistedData.token });
|
||||
@@ -194,6 +206,9 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
this.selectedProviderType = persistedData.selectedProviderType;
|
||||
loadedCachedProviderType = true;
|
||||
}
|
||||
if (persistedData.emailSent !== undefined) {
|
||||
this.emailSent = persistedData.emailSent;
|
||||
}
|
||||
}
|
||||
|
||||
// Only set default 2FA provider type if we don't have one from cache
|
||||
@@ -218,20 +233,17 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
async saveFormDataWithPartialData(data: Partial<TwoFactorFormCacheData>) {
|
||||
// Get current cached data
|
||||
const currentData = (await this.twoFactorFormCacheService.getFormData()) || {};
|
||||
const currentData = this.twoFactorFormCacheService.getCachedTwoFactorFormData();
|
||||
|
||||
// Only update fields that are present in the data object
|
||||
const updatedData: TwoFactorFormCacheData = {
|
||||
...currentData,
|
||||
...Object.entries(data).reduce((acc, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any),
|
||||
};
|
||||
|
||||
await this.twoFactorFormCacheService.saveFormData(updatedData);
|
||||
this.twoFactorFormCacheService.cacheTwoFactorFormData({
|
||||
token: data?.token ?? currentData?.token ?? "",
|
||||
remember: data?.remember ?? currentData?.remember ?? false,
|
||||
selectedProviderType:
|
||||
data?.selectedProviderType ??
|
||||
currentData?.selectedProviderType ??
|
||||
TwoFactorProviderType.Authenticator,
|
||||
emailSent: data?.emailSent ?? currentData?.emailSent ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,7 +347,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
const rememberValue = remember ?? this.rememberFormControl.value ?? false;
|
||||
|
||||
// Persist form data before submitting
|
||||
await this.twoFactorFormCacheService.saveFormData({
|
||||
this.twoFactorFormCacheService.cacheTwoFactorFormData({
|
||||
token: tokenValue,
|
||||
remember: rememberValue,
|
||||
selectedProviderType: this.selectedProviderType,
|
||||
@@ -363,11 +375,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
async selectOtherTwoFactorMethod() {
|
||||
// Persist current form data before navigating to another method
|
||||
await this.twoFactorFormCacheService.saveFormData({
|
||||
token: undefined,
|
||||
remember: undefined,
|
||||
this.twoFactorFormCacheService.cacheTwoFactorFormData({
|
||||
token: "",
|
||||
remember: false,
|
||||
selectedProviderType: this.selectedProviderType,
|
||||
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
|
||||
emailSent: false,
|
||||
});
|
||||
|
||||
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);
|
||||
@@ -384,11 +396,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
await this.setAnonLayoutDataByTwoFactorProviderType();
|
||||
|
||||
// Update the persisted provider type when a new one is chosen
|
||||
await this.twoFactorFormCacheService.saveFormData({
|
||||
token: undefined,
|
||||
remember: undefined,
|
||||
this.twoFactorFormCacheService.cacheTwoFactorFormData({
|
||||
token: "",
|
||||
remember: false,
|
||||
selectedProviderType: response.type,
|
||||
emailSent: false, // Reset email sent state when switching providers
|
||||
emailSent: false,
|
||||
});
|
||||
|
||||
this.form.reset();
|
||||
@@ -469,7 +481,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async handleAuthResult(authResult: AuthResult) {
|
||||
// Clear form cache
|
||||
await this.twoFactorFormCacheService.clearFormData();
|
||||
this.twoFactorFormCacheService.clearCachedTwoFactorFormData();
|
||||
|
||||
if (await this.handleMigrateEncryptionKey(authResult)) {
|
||||
return; // stop login process
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { TwoFactorFormView } from "@bitwarden/common/auth/models/view/two-factor-form.view";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
const TWO_FACTOR_FORM_CACHE_KEY = "two-factor-form-cache";
|
||||
|
||||
export interface TwoFactorFormData {
|
||||
token?: string;
|
||||
remember?: boolean;
|
||||
selectedProviderType?: TwoFactorProviderType;
|
||||
emailSent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a cache service used for the login via auth request component.
|
||||
*
|
||||
* There is sensitive information stored temporarily here. Cache will be cleared
|
||||
* after 2 minutes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DefaultTwoFactorFormCacheService {
|
||||
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||
private configService: ConfigService = inject(ConfigService);
|
||||
|
||||
/** True when the `PM9115_TwoFactorExtensionDataPersistence` flag is enabled */
|
||||
private featureEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Signal for the cached TwoFactorFormData.
|
||||
*/
|
||||
private defaultTwoFactorFormCache: WritableSignal<TwoFactorFormView | null> =
|
||||
this.viewCacheService.signal<TwoFactorFormView | null>({
|
||||
key: TWO_FACTOR_FORM_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: TwoFactorFormView.fromJSON,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Must be called once before interacting with the cached data, otherwise methods will be noop.
|
||||
*/
|
||||
async init() {
|
||||
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache with the new TwoFactorFormData.
|
||||
*/
|
||||
cacheTwoFactorFormData(data: TwoFactorFormData): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultTwoFactorFormCache.set({
|
||||
token: data.token,
|
||||
remember: data.remember,
|
||||
selectedProviderType: data.selectedProviderType,
|
||||
emailSent: data.emailSent,
|
||||
} as TwoFactorFormView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached TwoFactorFormData.
|
||||
*/
|
||||
clearCachedTwoFactorFormData(): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultTwoFactorFormCache.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached TwoFactorFormData when available.
|
||||
*/
|
||||
getCachedTwoFactorFormData(): TwoFactorFormView | null {
|
||||
if (!this.featureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.defaultTwoFactorFormCache();
|
||||
}
|
||||
}
|
||||
19
libs/common/src/auth/models/view/two-factor-form.view.ts
Normal file
19
libs/common/src/auth/models/view/two-factor-form.view.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
|
||||
|
||||
/**
|
||||
* This is a cache model for the two factor form.
|
||||
*/
|
||||
export class TwoFactorFormView implements View {
|
||||
token: string | undefined = undefined;
|
||||
remember: boolean | undefined = undefined;
|
||||
selectedProviderType: TwoFactorProviderType | undefined = undefined;
|
||||
emailSent: boolean | undefined = undefined;
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<TwoFactorFormView>>): TwoFactorFormView {
|
||||
return Object.assign(new TwoFactorFormView(), obj);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user