mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-13 00:51:45 +01:00
[PM-9111] Extension: persist add/edit form (#12236)
* remove todo * Retrieve cache cipher for add-edit form * user prefilled cipher for add-edit form * add listener for clearing view cache * clear local cache when clearing global state * track initial value of cache for down stream logic that should only occur on non-cached values * add feature flag for edit form persistence * add tests for cipher form cache service * fix optional initialValues * add services to cipher form storybook * fix strict types * rename variables to be platform agnostic * use deconstructed collectionIds variable to avoid them be overwritten * use the originalCipherView for initial values * add comment about signal equality * prevent events from being emitted when adding uris to the existing form - This stops other values from being overwrote in the initialization process * add check for cached cipher when adding initial uris
This commit is contained in:
parent
1dfae06856
commit
5c32e5020d
@ -133,6 +133,7 @@ export class PopupViewCacheService implements ViewCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearState() {
|
private clearState() {
|
||||||
|
this._cache = {}; // clear local cache
|
||||||
this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {});
|
this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -196,13 +196,15 @@ describe("popup view cache", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should clear on 2nd navigation", async () => {
|
it("should clear on 2nd navigation", async () => {
|
||||||
await initServiceWithState({});
|
await initServiceWithState({ temp: "state" });
|
||||||
|
|
||||||
await router.navigate(["a"]);
|
await router.navigate(["a"]);
|
||||||
expect(messageSenderMock.send).toHaveBeenCalledTimes(0);
|
expect(messageSenderMock.send).toHaveBeenCalledTimes(0);
|
||||||
|
expect(service["_cache"]).toEqual({ temp: "state" });
|
||||||
|
|
||||||
await router.navigate(["b"]);
|
await router.navigate(["b"]);
|
||||||
expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {});
|
expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {});
|
||||||
|
expect(service["_cache"]).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should ignore cached values when feature flag is off", async () => {
|
it("should ignore cached values when feature flag is off", async () => {
|
||||||
|
@ -60,6 +60,11 @@ export class PopupViewCacheBackgroundService {
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
this.messageListener
|
||||||
|
.messages$(ClEAR_VIEW_CACHE_COMMAND)
|
||||||
|
.pipe(concatMap(() => this.popupViewCacheState.update(() => null)))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
merge(
|
merge(
|
||||||
// on tab changed, excluding extension tabs
|
// on tab changed, excluding extension tabs
|
||||||
fromChromeEvent(chrome.tabs.onActivated).pipe(
|
fromChromeEvent(chrome.tabs.onActivated).pipe(
|
||||||
|
@ -44,6 +44,7 @@ export enum FeatureFlag {
|
|||||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||||
DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship",
|
DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship",
|
||||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||||
|
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||||
}
|
}
|
||||||
@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||||
[FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE,
|
[FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE,
|
||||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||||
|
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||||
|
@ -14,7 +14,6 @@ import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-secti
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
|
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
|
||||||
* TODO: Add additional form sections as they are implemented.
|
|
||||||
*/
|
*/
|
||||||
export type CipherForm = {
|
export type CipherForm = {
|
||||||
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
||||||
@ -57,4 +56,12 @@ export abstract class CipherFormContainer {
|
|||||||
* @param updateFn - A function that takes the current cipherView and returns the updated cipherView
|
* @param updateFn - A function that takes the current cipherView and returns the updated cipherView
|
||||||
*/
|
*/
|
||||||
abstract patchCipher(updateFn: (current: CipherView) => CipherView): void;
|
abstract patchCipher(updateFn: (current: CipherView) => CipherView): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns initial values for the CipherView, either from the config or the cached cipher
|
||||||
|
*/
|
||||||
|
abstract getInitialCipherView(): CipherView | null;
|
||||||
|
|
||||||
|
/** Returns true when the `CipherFormContainer` was initialized with a cached cipher available. */
|
||||||
|
abstract initializedWithCachedCipher(): boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { importProvidersFrom } from "@angular/core";
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
|
// @ts-strict-ignore
|
||||||
|
import { importProvidersFrom, signal } from "@angular/core";
|
||||||
import { action } from "@storybook/addon-actions";
|
import { action } from "@storybook/addon-actions";
|
||||||
import {
|
import {
|
||||||
applicationConfig,
|
applicationConfig,
|
||||||
@ -10,6 +12,7 @@ import {
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||||
|
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
@ -18,6 +21,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
|
|||||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||||
import { ClientType } from "@bitwarden/common/enums";
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||||
|
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 { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||||
@ -39,6 +43,7 @@ import { CipherFormService } from "./abstractions/cipher-form.service";
|
|||||||
import { TotpCaptureService } from "./abstractions/totp-capture.service";
|
import { TotpCaptureService } from "./abstractions/totp-capture.service";
|
||||||
import { CipherFormModule } from "./cipher-form.module";
|
import { CipherFormModule } from "./cipher-form.module";
|
||||||
import { CipherFormComponent } from "./components/cipher-form.component";
|
import { CipherFormComponent } from "./components/cipher-form.component";
|
||||||
|
import { CipherFormCacheService } from "./services/default-cipher-form-cache.service";
|
||||||
|
|
||||||
const defaultConfig: CipherFormConfig = {
|
const defaultConfig: CipherFormConfig = {
|
||||||
mode: "add",
|
mode: "add",
|
||||||
@ -192,6 +197,25 @@ export default {
|
|||||||
activeAccount$: new BehaviorSubject({ email: "test@example.com" }),
|
activeAccount$: new BehaviorSubject({ email: "test@example.com" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CipherFormCacheService,
|
||||||
|
useValue: {
|
||||||
|
getCachedCipherView: (): null => null,
|
||||||
|
initializedWithValue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ViewCacheService,
|
||||||
|
useValue: {
|
||||||
|
signal: () => signal(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
getFeatureFlag: () => Promise.resolve(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
componentWrapperDecorator(
|
componentWrapperDecorator(
|
||||||
|
@ -27,9 +27,12 @@ describe("AdditionalOptionsSectionComponent", () => {
|
|||||||
let passwordRepromptService: MockProxy<PasswordRepromptService>;
|
let passwordRepromptService: MockProxy<PasswordRepromptService>;
|
||||||
let passwordRepromptEnabled$: BehaviorSubject<boolean>;
|
let passwordRepromptEnabled$: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
cipherFormProvider = mock<CipherFormContainer>();
|
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
getInitialCipherView.mockClear();
|
||||||
|
|
||||||
|
cipherFormProvider = mock<CipherFormContainer>({ getInitialCipherView });
|
||||||
passwordRepromptService = mock<PasswordRepromptService>();
|
passwordRepromptService = mock<PasswordRepromptService>();
|
||||||
passwordRepromptEnabled$ = new BehaviorSubject(true);
|
passwordRepromptEnabled$ = new BehaviorSubject(true);
|
||||||
passwordRepromptService.enabled$ = passwordRepromptEnabled$;
|
passwordRepromptService.enabled$ = passwordRepromptEnabled$;
|
||||||
@ -94,11 +97,11 @@ describe("AdditionalOptionsSectionComponent", () => {
|
|||||||
expect(component.additionalOptionsForm.disabled).toBe(true);
|
expect(component.additionalOptionsForm.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes 'additionalOptionsForm' with original cipher view values", () => {
|
it("initializes 'additionalOptionsForm' from `getInitialCipherValue`", () => {
|
||||||
(cipherFormProvider.originalCipherView as any) = {
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
notes: "original notes",
|
notes: "original notes",
|
||||||
reprompt: 1,
|
reprompt: 1,
|
||||||
} as CipherView;
|
} as CipherView);
|
||||||
|
|
||||||
component.ngOnInit();
|
component.ngOnInit();
|
||||||
|
|
||||||
|
@ -77,11 +77,12 @@ export class AdditionalOptionsSectionComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.cipherFormContainer.originalCipherView) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
|
|
||||||
|
if (prefillCipher) {
|
||||||
this.additionalOptionsForm.patchValue({
|
this.additionalOptionsForm.patchValue({
|
||||||
notes: this.cipherFormContainer.originalCipherView.notes,
|
notes: prefillCipher.notes,
|
||||||
reprompt:
|
reprompt: prefillCipher.reprompt === CipherRepromptType.Password,
|
||||||
this.cipherFormContainer.originalCipherView.reprompt === CipherRepromptType.Password,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,9 +25,11 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||||||
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
||||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||||
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cipherFormContainer = mock<CipherFormContainer>();
|
getInitialCipherView.mockClear();
|
||||||
|
cipherFormContainer = mock<CipherFormContainer>({ getInitialCipherView });
|
||||||
liveAnnouncer = mock<LiveAnnouncer>();
|
liveAnnouncer = mock<LiveAnnouncer>();
|
||||||
platformUtilsService = mock<PlatformUtilsService>();
|
platformUtilsService = mock<PlatformUtilsService>();
|
||||||
domainSettingsService = mock<DomainSettingsService>();
|
domainSettingsService = mock<DomainSettingsService>();
|
||||||
@ -107,12 +109,14 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
existingLogin.uri = "https://example.com";
|
existingLogin.uri = "https://example.com";
|
||||||
existingLogin.match = UriMatchStrategy.Exact;
|
existingLogin.match = UriMatchStrategy.Exact;
|
||||||
|
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
|
const cipher = new CipherView();
|
||||||
cipherFormContainer.originalCipherView.login = {
|
cipher.login = {
|
||||||
autofillOnPageLoad: true,
|
autofillOnPageLoad: true,
|
||||||
uris: [existingLogin],
|
uris: [existingLogin],
|
||||||
} as LoginView;
|
} as LoginView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce(cipher);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.autofillOptionsForm.value.uris).toEqual([
|
expect(component.autofillOptionsForm.value.uris).toEqual([
|
||||||
@ -138,12 +142,14 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
existingLogin.uri = "https://example.com";
|
existingLogin.uri = "https://example.com";
|
||||||
existingLogin.match = UriMatchStrategy.Exact;
|
existingLogin.match = UriMatchStrategy.Exact;
|
||||||
|
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
|
const cipher = new CipherView();
|
||||||
cipherFormContainer.originalCipherView.login = {
|
cipher.login = {
|
||||||
autofillOnPageLoad: true,
|
autofillOnPageLoad: true,
|
||||||
uris: [existingLogin],
|
uris: [existingLogin],
|
||||||
} as LoginView;
|
} as LoginView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce(cipher);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.autofillOptionsForm.value.uris).toEqual([
|
expect(component.autofillOptionsForm.value.uris).toEqual([
|
||||||
@ -159,12 +165,14 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
existingLogin.uri = "https://example.com";
|
existingLogin.uri = "https://example.com";
|
||||||
existingLogin.match = UriMatchStrategy.Exact;
|
existingLogin.match = UriMatchStrategy.Exact;
|
||||||
|
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
|
const cipher = new CipherView();
|
||||||
cipherFormContainer.originalCipherView.login = {
|
cipher.login = {
|
||||||
autofillOnPageLoad: true,
|
autofillOnPageLoad: true,
|
||||||
uris: [existingLogin],
|
uris: [existingLogin],
|
||||||
} as LoginView;
|
} as LoginView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce(cipher);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.autofillOptionsForm.value.uris).toEqual([
|
expect(component.autofillOptionsForm.value.uris).toEqual([
|
||||||
|
@ -130,8 +130,9 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.cipherFormContainer.originalCipherView?.login) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login);
|
if (prefillCipher) {
|
||||||
|
this.initFromExistingCipher(prefillCipher.login);
|
||||||
} else {
|
} else {
|
||||||
this.initNewCipher();
|
this.initNewCipher();
|
||||||
}
|
}
|
||||||
@ -142,17 +143,29 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initFromExistingCipher(existingLogin: LoginView) {
|
private initFromExistingCipher(existingLogin: LoginView) {
|
||||||
|
// The `uris` control is a FormArray which needs to dynamically
|
||||||
|
// add controls to the form. Doing this will trigger the `valueChanges` observable on the form
|
||||||
|
// and overwrite the `autofillOnPageLoad` value before it is set in the following `patchValue` call.
|
||||||
|
// Pass `false` to `addUri` to stop events from emitting when adding the URIs.
|
||||||
existingLogin.uris?.forEach((uri) => {
|
existingLogin.uris?.forEach((uri) => {
|
||||||
this.addUri({
|
this.addUri(
|
||||||
|
{
|
||||||
uri: uri.uri,
|
uri: uri.uri,
|
||||||
matchDetection: uri.match,
|
matchDetection: uri.match,
|
||||||
});
|
},
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
this.autofillOptionsForm.patchValue({
|
this.autofillOptionsForm.patchValue({
|
||||||
autofillOnPageLoad: existingLogin.autofillOnPageLoad,
|
autofillOnPageLoad: existingLogin.autofillOnPageLoad,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.cipherFormContainer.config.initialValues?.loginUri) {
|
// Only add the initial value when the cipher was not initialized from a cached state
|
||||||
|
if (
|
||||||
|
this.cipherFormContainer.config.initialValues?.loginUri &&
|
||||||
|
!this.cipherFormContainer.initializedWithCachedCipher()
|
||||||
|
) {
|
||||||
// Avoid adding the same uri again if it already exists
|
// Avoid adding the same uri again if it already exists
|
||||||
if (
|
if (
|
||||||
existingLogin.uris?.findIndex(
|
existingLogin.uris?.findIndex(
|
||||||
@ -197,9 +210,16 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
* Adds a new URI input to the form.
|
* Adds a new URI input to the form.
|
||||||
* @param uriFieldValue The initial value for the new URI input.
|
* @param uriFieldValue The initial value for the new URI input.
|
||||||
* @param focusNewInput If true, the new URI input will be focused after being added.
|
* @param focusNewInput If true, the new URI input will be focused after being added.
|
||||||
|
* @param emitEvent When false, prevents the `valueChanges` & `statusChanges` observables from firing.
|
||||||
*/
|
*/
|
||||||
addUri(uriFieldValue: UriField = { uri: null, matchDetection: null }, focusNewInput = false) {
|
addUri(
|
||||||
this.autofillOptionsForm.controls.uris.push(this.formBuilder.control(uriFieldValue));
|
uriFieldValue: UriField = { uri: null, matchDetection: null },
|
||||||
|
focusNewInput = false,
|
||||||
|
emitEvent = true,
|
||||||
|
) {
|
||||||
|
this.autofillOptionsForm.controls.uris.push(this.formBuilder.control(uriFieldValue), {
|
||||||
|
emitEvent,
|
||||||
|
});
|
||||||
|
|
||||||
if (focusNewInput) {
|
if (focusNewInput) {
|
||||||
this.focusOnNewInput$.next();
|
this.focusOnNewInput$.next();
|
||||||
|
@ -20,8 +20,10 @@ describe("CardDetailsSectionComponent", () => {
|
|||||||
let registerChildFormSpy: jest.SpyInstance;
|
let registerChildFormSpy: jest.SpyInstance;
|
||||||
let patchCipherSpy: jest.SpyInstance;
|
let patchCipherSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cipherFormProvider = mock<CipherFormContainer>();
|
cipherFormProvider = mock<CipherFormContainer>({ getInitialCipherView });
|
||||||
registerChildFormSpy = jest.spyOn(cipherFormProvider, "registerChildForm");
|
registerChildFormSpy = jest.spyOn(cipherFormProvider, "registerChildForm");
|
||||||
patchCipherSpy = jest.spyOn(cipherFormProvider, "patchCipher");
|
patchCipherSpy = jest.spyOn(cipherFormProvider, "patchCipher");
|
||||||
|
|
||||||
@ -94,7 +96,7 @@ describe("CardDetailsSectionComponent", () => {
|
|||||||
expect(component.cardDetailsForm.disabled).toBe(true);
|
expect(component.cardDetailsForm.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes `cardDetailsForm` with current values", () => {
|
it("initializes `cardDetailsForm` from `getInitialCipherValue`", () => {
|
||||||
const cardholderName = "Ron Burgundy";
|
const cardholderName = "Ron Burgundy";
|
||||||
const number = "4242 4242 4242 4242";
|
const number = "4242 4242 4242 4242";
|
||||||
const code = "619";
|
const code = "619";
|
||||||
@ -105,9 +107,7 @@ describe("CardDetailsSectionComponent", () => {
|
|||||||
cardView.code = code;
|
cardView.code = code;
|
||||||
cardView.brand = "Visa";
|
cardView.brand = "Visa";
|
||||||
|
|
||||||
component.originalCipherView = {
|
getInitialCipherView.mockReturnValueOnce({ card: cardView });
|
||||||
card: cardView,
|
|
||||||
} as CipherView;
|
|
||||||
|
|
||||||
component.ngOnInit();
|
component.ngOnInit();
|
||||||
|
|
||||||
|
@ -136,8 +136,10 @@ export class CardDetailsSectionComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.originalCipherView?.card) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
this.setInitialValues();
|
|
||||||
|
if (prefillCipher) {
|
||||||
|
this.setInitialValues(prefillCipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
@ -172,8 +174,8 @@ export class CardDetailsSectionComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Set form initial form values from the current cipher */
|
/** Set form initial form values from the current cipher */
|
||||||
private setInitialValues() {
|
private setInitialValues(cipherView: CipherView) {
|
||||||
const { cardholderName, number, brand, expMonth, expYear, code } = this.originalCipherView.card;
|
const { cardholderName, number, brand, expMonth, expYear, code } = cipherView.card;
|
||||||
|
|
||||||
this.cardDetailsForm.setValue({
|
this.cardDetailsForm.setValue({
|
||||||
cardholderName: cardholderName,
|
cardholderName: cardholderName,
|
||||||
|
@ -38,6 +38,7 @@ import {
|
|||||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||||
import { CipherForm, CipherFormContainer } from "../cipher-form-container";
|
import { CipherForm, CipherFormContainer } from "../cipher-form-container";
|
||||||
|
import { CipherFormCacheService } from "../services/default-cipher-form-cache.service";
|
||||||
|
|
||||||
import { AdditionalOptionsSectionComponent } from "./additional-options/additional-options-section.component";
|
import { AdditionalOptionsSectionComponent } from "./additional-options/additional-options-section.component";
|
||||||
import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
||||||
@ -55,6 +56,9 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen
|
|||||||
provide: CipherFormContainer,
|
provide: CipherFormContainer,
|
||||||
useExisting: forwardRef(() => CipherFormComponent),
|
useExisting: forwardRef(() => CipherFormComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CipherFormCacheService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
AsyncActionsModule,
|
AsyncActionsModule,
|
||||||
@ -164,6 +168,26 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
*/
|
*/
|
||||||
patchCipher(updateFn: (current: CipherView) => CipherView): void {
|
patchCipher(updateFn: (current: CipherView) => CipherView): void {
|
||||||
this.updatedCipherView = updateFn(this.updatedCipherView);
|
this.updatedCipherView = updateFn(this.updatedCipherView);
|
||||||
|
// Cache the updated cipher
|
||||||
|
this.cipherFormCacheService.cacheCipherView(this.updatedCipherView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return initial values for given keys of a cipher
|
||||||
|
*/
|
||||||
|
getInitialCipherView(): CipherView {
|
||||||
|
const cachedCipherView = this.cipherFormCacheService.getCachedCipherView();
|
||||||
|
|
||||||
|
if (cachedCipherView) {
|
||||||
|
return cachedCipherView;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.originalCipherView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** */
|
||||||
|
initializedWithCachedCipher(): boolean {
|
||||||
|
return this.cipherFormCacheService.initializedWithValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,6 +211,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
// Force change detection so that all child components are destroyed and re-created
|
// Force change detection so that all child components are destroyed and re-created
|
||||||
this.changeDetectorRef.detectChanges();
|
this.changeDetectorRef.detectChanges();
|
||||||
|
|
||||||
|
await this.cipherFormCacheService.init();
|
||||||
|
|
||||||
this.updatedCipherView = new CipherView();
|
this.updatedCipherView = new CipherView();
|
||||||
this.originalCipherView = null;
|
this.originalCipherView = null;
|
||||||
this.cipherForm = this.formBuilder.group<CipherForm>({});
|
this.cipherForm = this.formBuilder.group<CipherForm>({});
|
||||||
@ -220,16 +246,39 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setInitialCipherFromCache();
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.formReadySubject.next();
|
this.formReadySubject.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates `updatedCipherView` based on the value from the cache.
|
||||||
|
*/
|
||||||
|
setInitialCipherFromCache() {
|
||||||
|
const cachedCipher = this.cipherFormCacheService.getCachedCipherView();
|
||||||
|
if (cachedCipher === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the cached cipher when it matches the cipher being edited
|
||||||
|
if (this.updatedCipherView.id === cachedCipher.id) {
|
||||||
|
this.updatedCipherView = cachedCipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `id` is null when a cipher is being added
|
||||||
|
if (this.updatedCipherView.id === null) {
|
||||||
|
this.updatedCipherView = cachedCipher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private addEditFormService: CipherFormService,
|
private addEditFormService: CipherFormService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private cipherFormCacheService: CipherFormCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -61,7 +61,13 @@ describe("CustomFieldsComponent", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: CipherFormContainer,
|
provide: CipherFormContainer,
|
||||||
useValue: { patchCipher, originalCipherView, registerChildForm: jest.fn(), config },
|
useValue: {
|
||||||
|
patchCipher,
|
||||||
|
originalCipherView,
|
||||||
|
registerChildForm: jest.fn(),
|
||||||
|
config,
|
||||||
|
getInitialCipherView: jest.fn(() => originalCipherView),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: LiveAnnouncer,
|
provide: LiveAnnouncer,
|
||||||
|
@ -148,8 +148,10 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
|||||||
value: id,
|
value: id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Populate the form with the existing fields
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
this.cipherFormContainer.originalCipherView?.fields?.forEach((field) => {
|
|
||||||
|
// When available, populate the form with the existing fields
|
||||||
|
prefillCipher.fields?.forEach((field) => {
|
||||||
let value: string | boolean = field.value;
|
let value: string | boolean = field.value;
|
||||||
|
|
||||||
if (field.type === FieldType.Boolean) {
|
if (field.type === FieldType.Boolean) {
|
||||||
|
@ -113,8 +113,10 @@ export class IdentitySectionComponent implements OnInit {
|
|||||||
this.identityForm.disable();
|
this.identityForm.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.originalCipherView && this.originalCipherView.id) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
this.populateFormData();
|
|
||||||
|
if (prefillCipher) {
|
||||||
|
this.populateFormData(prefillCipher);
|
||||||
} else {
|
} else {
|
||||||
this.identityForm.patchValue({
|
this.identityForm.patchValue({
|
||||||
username: this.cipherFormContainer.config.initialValues?.username || "",
|
username: this.cipherFormContainer.config.initialValues?.username || "",
|
||||||
@ -122,8 +124,9 @@ export class IdentitySectionComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
populateFormData() {
|
populateFormData(cipherView: CipherView) {
|
||||||
const { identity } = this.originalCipherView;
|
const { identity } = cipherView;
|
||||||
|
|
||||||
this.identityForm.setValue({
|
this.identityForm.setValue({
|
||||||
title: identity.title,
|
title: identity.title,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
|
@ -9,7 +9,6 @@ import { CollectionView } from "@bitwarden/admin-console/common";
|
|||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { SelectComponent } from "@bitwarden/components";
|
import { SelectComponent } from "@bitwarden/components";
|
||||||
|
|
||||||
@ -25,9 +24,17 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
let i18nService: MockProxy<I18nService>;
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
|
||||||
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
|
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
|
||||||
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
const initializedWithCachedCipher = jest.fn(() => false);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cipherFormProvider = mock<CipherFormContainer>();
|
getInitialCipherView.mockClear();
|
||||||
|
initializedWithCachedCipher.mockClear();
|
||||||
|
|
||||||
|
cipherFormProvider = mock<CipherFormContainer>({
|
||||||
|
getInitialCipherView,
|
||||||
|
initializedWithCachedCipher,
|
||||||
|
});
|
||||||
i18nService = mock<I18nService>();
|
i18nService = mock<I18nService>();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@ -95,13 +102,14 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
readOnly: false,
|
readOnly: false,
|
||||||
} as CollectionView,
|
} as CollectionView,
|
||||||
];
|
];
|
||||||
component.originalCipherView = {
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
name: "cipher1",
|
name: "cipher1",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
folderId: "folder1",
|
folderId: "folder1",
|
||||||
collectionIds: ["col1"],
|
collectionIds: ["col1"],
|
||||||
favorite: true,
|
favorite: true,
|
||||||
} as CipherView;
|
});
|
||||||
|
|
||||||
await component.ngOnInit();
|
await component.ngOnInit();
|
||||||
tick();
|
tick();
|
||||||
@ -118,55 +126,6 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
expect(updatedCipher.favorite).toBe(true);
|
expect(updatedCipher.favorite).toBe(true);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it("should prioritize initialValues when editing an existing cipher ", fakeAsync(async () => {
|
|
||||||
component.config.allowPersonalOwnership = true;
|
|
||||||
component.config.organizations = [{ id: "org1" } as Organization];
|
|
||||||
component.config.collections = [
|
|
||||||
{
|
|
||||||
id: "col1",
|
|
||||||
name: "Collection 1",
|
|
||||||
organizationId: "org1",
|
|
||||||
assigned: true,
|
|
||||||
readOnly: true,
|
|
||||||
} as CollectionView,
|
|
||||||
{
|
|
||||||
id: "col2",
|
|
||||||
name: "Collection 2",
|
|
||||||
organizationId: "org1",
|
|
||||||
assigned: true,
|
|
||||||
readOnly: false,
|
|
||||||
} as CollectionView,
|
|
||||||
];
|
|
||||||
component.originalCipherView = {
|
|
||||||
name: "cipher1",
|
|
||||||
organizationId: "org1",
|
|
||||||
folderId: "folder1",
|
|
||||||
collectionIds: ["col1"],
|
|
||||||
favorite: true,
|
|
||||||
} as CipherView;
|
|
||||||
|
|
||||||
component.config.initialValues = {
|
|
||||||
name: "new-name",
|
|
||||||
folderId: "new-folder",
|
|
||||||
organizationId: "bad-org" as OrganizationId, // Should not be set in edit mode
|
|
||||||
collectionIds: ["col2" as CollectionId],
|
|
||||||
};
|
|
||||||
|
|
||||||
await component.ngOnInit();
|
|
||||||
tick();
|
|
||||||
|
|
||||||
expect(cipherFormProvider.patchCipher).toHaveBeenCalled();
|
|
||||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0];
|
|
||||||
|
|
||||||
const updatedCipher = patchFn(new CipherView());
|
|
||||||
|
|
||||||
expect(updatedCipher.name).toBe("new-name");
|
|
||||||
expect(updatedCipher.organizationId).toBe("org1");
|
|
||||||
expect(updatedCipher.folderId).toBe("new-folder");
|
|
||||||
expect(updatedCipher.collectionIds).toEqual(["col2"]);
|
|
||||||
expect(updatedCipher.favorite).toBe(true);
|
|
||||||
}));
|
|
||||||
|
|
||||||
it("should disable organizationId control if ownership change is not allowed", async () => {
|
it("should disable organizationId control if ownership change is not allowed", async () => {
|
||||||
component.config.allowPersonalOwnership = false;
|
component.config.allowPersonalOwnership = false;
|
||||||
component.config.organizations = [{ id: "org1" } as Organization];
|
component.config.organizations = [{ id: "org1" } as Organization];
|
||||||
@ -294,10 +253,13 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("cloneMode", () => {
|
describe("cloneMode", () => {
|
||||||
it("should append '- Clone' to the title if in clone mode", async () => {
|
beforeEach(() => {
|
||||||
component.config.mode = "clone";
|
component.config.mode = "clone";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append '- Clone' to the title if in clone mode", async () => {
|
||||||
component.config.allowPersonalOwnership = true;
|
component.config.allowPersonalOwnership = true;
|
||||||
component.originalCipherView = {
|
const cipher = {
|
||||||
name: "cipher1",
|
name: "cipher1",
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
@ -305,6 +267,8 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
} as CipherView;
|
} as CipherView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce(cipher);
|
||||||
|
|
||||||
i18nService.t.calledWith("clone").mockReturnValue("Clone");
|
i18nService.t.calledWith("clone").mockReturnValue("Clone");
|
||||||
|
|
||||||
await component.ngOnInit();
|
await component.ngOnInit();
|
||||||
@ -312,8 +276,28 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
expect(component.itemDetailsForm.controls.name.value).toBe("cipher1 - Clone");
|
expect(component.itemDetailsForm.controls.name.value).toBe("cipher1 - Clone");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not append clone when the cipher was populated from the cache", async () => {
|
||||||
|
component.config.allowPersonalOwnership = true;
|
||||||
|
const cipher = {
|
||||||
|
name: "from cache cipher",
|
||||||
|
organizationId: null,
|
||||||
|
folderId: null,
|
||||||
|
collectionIds: null,
|
||||||
|
favorite: false,
|
||||||
|
} as CipherView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValueOnce(cipher);
|
||||||
|
|
||||||
|
initializedWithCachedCipher.mockReturnValueOnce(true);
|
||||||
|
|
||||||
|
i18nService.t.calledWith("clone").mockReturnValue("Clone");
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.itemDetailsForm.controls.name.value).toBe("from cache cipher");
|
||||||
|
});
|
||||||
|
|
||||||
it("should select the first organization if personal ownership is not allowed", async () => {
|
it("should select the first organization if personal ownership is not allowed", async () => {
|
||||||
component.config.mode = "clone";
|
|
||||||
component.config.allowPersonalOwnership = false;
|
component.config.allowPersonalOwnership = false;
|
||||||
component.config.organizations = [
|
component.config.organizations = [
|
||||||
{ id: "org1" } as Organization,
|
{ id: "org1" } as Organization,
|
||||||
@ -376,13 +360,13 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
|
|
||||||
it("should set collectionIds to originalCipher collections on first load", async () => {
|
it("should set collectionIds to originalCipher collections on first load", async () => {
|
||||||
component.config.mode = "clone";
|
component.config.mode = "clone";
|
||||||
component.originalCipherView = {
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
name: "cipher1",
|
name: "cipher1",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
folderId: "folder1",
|
folderId: "folder1",
|
||||||
collectionIds: ["col1", "col2"],
|
collectionIds: ["col1", "col2"],
|
||||||
favorite: true,
|
favorite: true,
|
||||||
} as CipherView;
|
});
|
||||||
component.config.organizations = [{ id: "org1" } as Organization];
|
component.config.organizations = [{ id: "org1" } as Organization];
|
||||||
component.config.collections = [
|
component.config.collections = [
|
||||||
{
|
{
|
||||||
@ -447,6 +431,13 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
|
|
||||||
it("should show readonly hint if readonly collections are present", async () => {
|
it("should show readonly hint if readonly collections are present", async () => {
|
||||||
component.config.mode = "edit";
|
component.config.mode = "edit";
|
||||||
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
|
name: "cipher1",
|
||||||
|
organizationId: "org1",
|
||||||
|
folderId: "folder1",
|
||||||
|
collectionIds: ["col1", "col2", "col3"],
|
||||||
|
favorite: true,
|
||||||
|
});
|
||||||
component.originalCipherView = {
|
component.originalCipherView = {
|
||||||
name: "cipher1",
|
name: "cipher1",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
@ -559,6 +550,9 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
collectionIds: ["col1", "col2", "col3"],
|
collectionIds: ["col1", "col2", "col3"],
|
||||||
favorite: true,
|
favorite: true,
|
||||||
} as CipherView;
|
} as CipherView;
|
||||||
|
|
||||||
|
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||||
|
|
||||||
component.config.organizations = [{ id: "org1" } as Organization];
|
component.config.organizations = [{ id: "org1" } as Organization];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -183,8 +183,10 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
throw new Error("No organizations available for ownership.");
|
throw new Error("No organizations available for ownership.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.originalCipherView) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
await this.initFromExistingCipher();
|
|
||||||
|
if (prefillCipher) {
|
||||||
|
await this.initFromExistingCipher(prefillCipher);
|
||||||
} else {
|
} else {
|
||||||
this.itemDetailsForm.setValue({
|
this.itemDetailsForm.setValue({
|
||||||
name: this.initialValues?.name || "",
|
name: this.initialValues?.name || "",
|
||||||
@ -210,30 +212,37 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initFromExistingCipher() {
|
private async initFromExistingCipher(prefillCipher: CipherView) {
|
||||||
|
const { name, folderId, collectionIds } = prefillCipher;
|
||||||
|
|
||||||
this.itemDetailsForm.setValue({
|
this.itemDetailsForm.setValue({
|
||||||
name: this.initialValues?.name ?? this.originalCipherView.name,
|
name: name ? name : (this.initialValues?.name ?? ""),
|
||||||
organizationId: this.originalCipherView.organizationId, // We do not allow changing ownership of an existing cipher.
|
organizationId: prefillCipher.organizationId, // We do not allow changing ownership of an existing cipher.
|
||||||
folderId: this.initialValues?.folderId ?? this.originalCipherView.folderId,
|
folderId: folderId ? folderId : (this.initialValues?.folderId ?? null),
|
||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
favorite: this.originalCipherView.favorite,
|
favorite: prefillCipher.favorite,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initializedWithCachedCipher = this.cipherFormContainer.initializedWithCachedCipher();
|
||||||
|
|
||||||
// Configure form for clone mode.
|
// Configure form for clone mode.
|
||||||
if (this.config.mode === "clone") {
|
if (this.config.mode === "clone") {
|
||||||
|
if (!initializedWithCachedCipher) {
|
||||||
this.itemDetailsForm.controls.name.setValue(
|
this.itemDetailsForm.controls.name.setValue(
|
||||||
this.originalCipherView.name + " - " + this.i18nService.t("clone"),
|
prefillCipher.name + " - " + this.i18nService.t("clone"),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.allowPersonalOwnership && this.originalCipherView.organizationId == null) {
|
if (!this.allowPersonalOwnership && prefillCipher.organizationId == null) {
|
||||||
this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner);
|
this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCollectionOptions(
|
const prefillCollections = collectionIds?.length
|
||||||
this.initialValues?.collectionIds ??
|
? (collectionIds as CollectionId[])
|
||||||
(this.originalCipherView.collectionIds as CollectionId[]),
|
: (this.initialValues?.collectionIds ?? []);
|
||||||
);
|
|
||||||
|
await this.updateCollectionOptions(prefillCollections);
|
||||||
|
|
||||||
if (this.partialEdit) {
|
if (this.partialEdit) {
|
||||||
this.itemDetailsForm.disable();
|
this.itemDetailsForm.disable();
|
||||||
|
@ -45,9 +45,11 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
let configService: MockProxy<ConfigService>;
|
let configService: MockProxy<ConfigService>;
|
||||||
|
|
||||||
const collect = jest.fn().mockResolvedValue(null);
|
const collect = jest.fn().mockResolvedValue(null);
|
||||||
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cipherFormContainer = mock<CipherFormContainer>();
|
getInitialCipherView.mockClear();
|
||||||
|
cipherFormContainer = mock<CipherFormContainer>({ getInitialCipherView });
|
||||||
|
|
||||||
generationService = mock<CipherFormGenerationService>();
|
generationService = mock<CipherFormGenerationService>();
|
||||||
auditService = mock<AuditService>();
|
auditService = mock<AuditService>();
|
||||||
@ -122,18 +124,18 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("initializes 'loginDetailsForm' with original cipher view values", async () => {
|
it("initializes 'loginDetailsForm' with original cipher view values", async () => {
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = {
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
login: {
|
login: {
|
||||||
password: "original-password",
|
password: "original-password",
|
||||||
username: "original-username",
|
username: "original-username",
|
||||||
totp: "original-totp",
|
totp: "original-totp",
|
||||||
} as LoginView,
|
},
|
||||||
} as CipherView;
|
});
|
||||||
|
|
||||||
await component.ngOnInit();
|
component.ngOnInit();
|
||||||
|
|
||||||
expect(component.loginDetailsForm.value).toEqual({
|
expect(component.loginDetailsForm.getRawValue()).toEqual({
|
||||||
username: "original-username",
|
username: "original-username",
|
||||||
password: "original-password",
|
password: "original-password",
|
||||||
totp: "original-totp",
|
totp: "original-totp",
|
||||||
@ -141,22 +143,23 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("initializes 'loginDetailsForm' with initialValues that override any original cipher view values", async () => {
|
it("initializes 'loginDetailsForm' with initialValues that override any original cipher view values", async () => {
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = {
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
login: {
|
login: {
|
||||||
password: "original-password",
|
password: "original-password",
|
||||||
username: "original-username",
|
username: "original-username",
|
||||||
totp: "original-totp",
|
totp: "original-totp",
|
||||||
} as LoginView,
|
},
|
||||||
} as CipherView;
|
});
|
||||||
|
|
||||||
cipherFormContainer.config.initialValues = {
|
cipherFormContainer.config.initialValues = {
|
||||||
username: "new-username",
|
username: "new-username",
|
||||||
password: "new-password",
|
password: "new-password",
|
||||||
};
|
};
|
||||||
|
|
||||||
await component.ngOnInit();
|
component.ngOnInit();
|
||||||
|
|
||||||
expect(component.loginDetailsForm.value).toEqual({
|
expect(component.loginDetailsForm.getRawValue()).toEqual({
|
||||||
username: "new-username",
|
username: "new-username",
|
||||||
password: "new-password",
|
password: "new-password",
|
||||||
totp: "original-totp",
|
totp: "original-totp",
|
||||||
@ -165,12 +168,12 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
|
|
||||||
describe("viewHiddenFields", () => {
|
describe("viewHiddenFields", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = {
|
getInitialCipherView.mockReturnValue({
|
||||||
viewPassword: false,
|
viewPassword: false,
|
||||||
login: {
|
login: {
|
||||||
password: "original-password",
|
password: "original-password",
|
||||||
} as LoginView,
|
},
|
||||||
} as CipherView;
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns value of originalCipher.viewPassword", () => {
|
it("returns value of originalCipher.viewPassword", () => {
|
||||||
@ -251,6 +254,10 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("password", () => {
|
describe("password", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getInitialCipherView.mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
const getGeneratePasswordBtn = () =>
|
const getGeneratePasswordBtn = () =>
|
||||||
fixture.nativeElement.querySelector("button[data-testid='generate-password-button']");
|
fixture.nativeElement.querySelector("button[data-testid='generate-password-button']");
|
||||||
|
|
||||||
@ -520,11 +527,11 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
fixture.nativeElement.querySelector("input[data-testid='passkey-field']");
|
fixture.nativeElement.querySelector("input[data-testid='passkey-field']");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(cipherFormContainer.originalCipherView as CipherView) = {
|
getInitialCipherView.mockReturnValue({
|
||||||
login: Object.assign(new LoginView(), {
|
login: Object.assign(new LoginView(), {
|
||||||
fido2Credentials: [{ creationDate: passkeyDate } as Fido2CredentialView],
|
fido2Credentials: [{ creationDate: passkeyDate } as Fido2CredentialView],
|
||||||
}),
|
}),
|
||||||
} as CipherView;
|
});
|
||||||
|
|
||||||
fixture = TestBed.createComponent(LoginDetailsSectionComponent);
|
fixture = TestBed.createComponent(LoginDetailsSectionComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
@ -567,7 +574,11 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the passkey field when missing a passkey", () => {
|
it("hides the passkey field when missing a passkey", () => {
|
||||||
(cipherFormContainer.originalCipherView as CipherView).login.fido2Credentials = [];
|
getInitialCipherView.mockReturnValueOnce({
|
||||||
|
login: Object.assign(new LoginView(), {
|
||||||
|
fido2Credentials: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
@ -139,11 +139,13 @@ export class LoginDetailsSectionComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.cipherFormContainer.originalCipherView?.login) {
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||||
this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login);
|
|
||||||
|
if (prefillCipher) {
|
||||||
|
this.initFromExistingCipher(prefillCipher.login);
|
||||||
} else {
|
} else {
|
||||||
await this.initNewCipher();
|
this.initNewCipher();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
||||||
@ -166,7 +168,7 @@ export class LoginDetailsSectionComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initNewCipher() {
|
private initNewCipher() {
|
||||||
this.loginDetailsForm.patchValue({
|
this.loginDetailsForm.patchValue({
|
||||||
username: this.initialValues?.username || "",
|
username: this.initialValues?.username || "",
|
||||||
password: this.initialValues?.password || "",
|
password: this.initialValues?.password || "",
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
import { signal } from "@angular/core";
|
||||||
|
import { TestBed } from "@angular/core/testing";
|
||||||
|
|
||||||
|
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
|
import { CipherFormCacheService } from "./default-cipher-form-cache.service";
|
||||||
|
|
||||||
|
describe("CipherFormCacheService", () => {
|
||||||
|
let service: CipherFormCacheService;
|
||||||
|
let testBed: TestBed;
|
||||||
|
const cacheSignal = signal<CipherView | null>(null);
|
||||||
|
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
|
||||||
|
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||||
|
const cacheSetMock = jest.spyOn(cacheSignal, "set");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getCacheSignal.mockClear();
|
||||||
|
getFeatureFlag.mockClear();
|
||||||
|
cacheSetMock.mockClear();
|
||||||
|
|
||||||
|
testBed = TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
|
||||||
|
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||||
|
CipherFormCacheService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feature enabled", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
getFeatureFlag.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("`getCachedCipherView` returns the cipher", async () => {
|
||||||
|
cacheSignal.set({ id: "cipher-4" } as CipherView);
|
||||||
|
service = testBed.inject(CipherFormCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(service.getCachedCipherView()).toEqual({ id: "cipher-4" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the signal value", async () => {
|
||||||
|
service = testBed.inject(CipherFormCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
service.cacheCipherView({ id: "cipher-5" } as CipherView);
|
||||||
|
|
||||||
|
expect(cacheSignal.set).toHaveBeenCalledWith({ id: "cipher-5" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initializedWithValue", () => {
|
||||||
|
it("sets `initializedWithValue` to true when there is a cached cipher", async () => {
|
||||||
|
cacheSignal.set({ id: "cipher-3" } as CipherView);
|
||||||
|
service = testBed.inject(CipherFormCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(service.initializedWithValue).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets `initializedWithValue` to false when there is not a cached cipher", async () => {
|
||||||
|
cacheSignal.set(null);
|
||||||
|
service = testBed.inject(CipherFormCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(service.initializedWithValue).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("featured disabled", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
cacheSignal.set({ id: "cipher-1" } as CipherView);
|
||||||
|
getFeatureFlag.mockResolvedValue(false);
|
||||||
|
cacheSetMock.mockClear();
|
||||||
|
|
||||||
|
service = testBed.inject(CipherFormCacheService);
|
||||||
|
await service.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets `initializedWithValue` to false", () => {
|
||||||
|
expect(service.initializedWithValue).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("`getCachedCipherView` returns null", () => {
|
||||||
|
expect(service.getCachedCipherView()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update the signal value", () => {
|
||||||
|
service.cacheCipherView({ id: "cipher-2" } as CipherView);
|
||||||
|
|
||||||
|
expect(cacheSignal.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,73 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
|
const CIPHER_FORM_CACHE_KEY = "cipher-form-cache";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CipherFormCacheService {
|
||||||
|
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||||
|
private configService: ConfigService = inject(ConfigService);
|
||||||
|
|
||||||
|
/** True when the `PM9111ExtensionPersistAddEditForm` flag is enabled */
|
||||||
|
private featureEnabled: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true the `CipherFormCacheService` a cipher was stored in cache when the service was initialized.
|
||||||
|
* Otherwise false, when the cache was empty.
|
||||||
|
*
|
||||||
|
* This is helpful to know the initial state of the cache as it can be populated quickly after initialization.
|
||||||
|
*/
|
||||||
|
initializedWithValue: boolean;
|
||||||
|
|
||||||
|
private cipherCache = this.viewCacheService.signal<CipherView | null>({
|
||||||
|
key: CIPHER_FORM_CACHE_KEY,
|
||||||
|
initialValue: null,
|
||||||
|
deserializer: CipherView.fromJSON,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initializedWithValue = !!this.cipherCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called once before interacting with the cached cipher, otherwise methods will be noop.
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.PM9111ExtensionPersistAddEditForm,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
this.initializedWithValue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the cache with the new CipherView.
|
||||||
|
*/
|
||||||
|
cacheCipherView(cipherView: CipherView): void {
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new shallow reference to force the signal to update
|
||||||
|
// By default, signals use `Object.is` to determine equality
|
||||||
|
// Docs: https://angular.dev/guide/signals#signal-equality-functions
|
||||||
|
this.cipherCache.set({ ...cipherView } as CipherView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached CipherView when available.
|
||||||
|
*/
|
||||||
|
getCachedCipherView(): CipherView | null {
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.cipherCache();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user