mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 16:23:44 +00:00
[PM-22376] - [Vault] [Clients] Update cipher form component to default to My Items collections (#15356)
* fix tests * remove unused code * fix storybook * fix storybook * cleanup * move observable to function. update tests * fix type error * move call to getDefaultCollectionId * fix test
This commit is contained in:
@@ -19,6 +19,7 @@ import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
|||||||
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
|
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
|
||||||
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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
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 { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
@@ -243,6 +244,7 @@ export default {
|
|||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: {
|
useValue: {
|
||||||
getFeatureFlag: () => Promise.resolve(false),
|
getFeatureFlag: () => Promise.resolve(false),
|
||||||
|
getFeatureFlag$: () => new BehaviorSubject(false),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -253,6 +255,12 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PolicyService,
|
||||||
|
useValue: {
|
||||||
|
policiesByType$: new BehaviorSubject([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
componentWrapperDecorator(
|
componentWrapperDecorator(
|
||||||
|
|||||||
@@ -3,18 +3,24 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin
|
|||||||
import { ReactiveFormsModule } from "@angular/forms";
|
import { ReactiveFormsModule } from "@angular/forms";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
|
|
||||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
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";
|
||||||
|
|
||||||
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
|
import {
|
||||||
|
CipherFormConfig,
|
||||||
|
OptionalInitialValues,
|
||||||
|
} from "../../abstractions/cipher-form-config.service";
|
||||||
import { CipherFormContainer } from "../../cipher-form-container";
|
import { CipherFormContainer } from "../../cipher-form-container";
|
||||||
|
|
||||||
import { ItemDetailsSectionComponent } from "./item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./item-details-section.component";
|
||||||
@@ -48,6 +54,8 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
let fixture: ComponentFixture<ItemDetailsSectionComponent>;
|
let fixture: ComponentFixture<ItemDetailsSectionComponent>;
|
||||||
let cipherFormProvider: MockProxy<CipherFormContainer>;
|
let cipherFormProvider: MockProxy<CipherFormContainer>;
|
||||||
let i18nService: MockProxy<I18nService>;
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
let mockConfigService: MockProxy<ConfigService>;
|
||||||
|
let mockPolicyService: MockProxy<PolicyService>;
|
||||||
|
|
||||||
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 getInitialCipherView = jest.fn(() => null);
|
||||||
@@ -66,12 +74,19 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
compare: (a: string, b: string) => a.localeCompare(b),
|
compare: (a: string, b: string) => a.localeCompare(b),
|
||||||
} as Intl.Collator;
|
} as Intl.Collator;
|
||||||
|
|
||||||
|
mockConfigService = mock<ConfigService>();
|
||||||
|
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
mockPolicyService = mock<PolicyService>();
|
||||||
|
mockPolicyService.policiesByType$.mockReturnValue(of([]));
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
||||||
{ provide: I18nService, useValue: i18nService },
|
{ provide: I18nService, useValue: i18nService },
|
||||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: PolicyService, useValue: mockPolicyService },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
@@ -369,7 +384,7 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
expect(collectionSelect).toBeNull();
|
expect(collectionSelect).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enable/show collection control when an organization is selected", async () => {
|
it("should enable/show collection control when an organization is selected", fakeAsync(() => {
|
||||||
component.config.organizationDataOwnershipDisabled = true;
|
component.config.organizationDataOwnershipDisabled = true;
|
||||||
component.config.organizations = [{ id: "org1" } as Organization];
|
component.config.organizations = [{ id: "org1" } as Organization];
|
||||||
component.config.collections = [
|
component.config.collections = [
|
||||||
@@ -378,12 +393,12 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
tick();
|
||||||
|
|
||||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||||
|
|
||||||
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
const collectionSelect = fixture.nativeElement.querySelector(
|
const collectionSelect = fixture.nativeElement.querySelector(
|
||||||
"bit-multi-select[formcontrolname='collectionIds']",
|
"bit-multi-select[formcontrolname='collectionIds']",
|
||||||
@@ -391,7 +406,7 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
|
|
||||||
expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true);
|
expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true);
|
||||||
expect(collectionSelect).not.toBeNull();
|
expect(collectionSelect).not.toBeNull();
|
||||||
});
|
}));
|
||||||
|
|
||||||
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";
|
||||||
@@ -488,6 +503,9 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
|
|
||||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
|
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -548,4 +566,27 @@ describe("ItemDetailsSectionComponent", () => {
|
|||||||
expect(label).toBe("org1");
|
expect(label).toBe("org1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getDefaultCollectionId", () => {
|
||||||
|
it("returns matching default when flag & policy match", async () => {
|
||||||
|
const def = createMockCollection("def1", "Def", "orgA");
|
||||||
|
component.config.collections = [def] as CollectionView[];
|
||||||
|
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
|
||||||
|
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
|
||||||
|
|
||||||
|
const id = await (component as any).getDefaultCollectionId("orgA");
|
||||||
|
expect(id).toEqual("def1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no default found", async () => {
|
||||||
|
component.config.collections = [createMockCollection("c1", "C1", "orgB")] as CollectionView[];
|
||||||
|
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
|
||||||
|
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
|
||||||
|
|
||||||
|
const result = await (component as any).getDefaultCollectionId("orgA");
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import { CommonModule } from "@angular/common";
|
|||||||
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
import { concatMap, map } from "rxjs";
|
import { concatMap, firstValueFrom, map } from "rxjs";
|
||||||
|
|
||||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
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 { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
@@ -124,6 +128,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private destroyRef: DestroyRef,
|
private destroyRef: DestroyRef,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private policyService: PolicyService,
|
||||||
) {
|
) {
|
||||||
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
||||||
this.itemDetailsForm.valueChanges
|
this.itemDetailsForm.valueChanges
|
||||||
@@ -200,30 +206,61 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
if (prefillCipher) {
|
if (prefillCipher) {
|
||||||
await this.initFromExistingCipher(prefillCipher);
|
await this.initFromExistingCipher(prefillCipher);
|
||||||
} else {
|
} else {
|
||||||
|
const orgId = this.initialValues?.organizationId;
|
||||||
this.itemDetailsForm.setValue({
|
this.itemDetailsForm.setValue({
|
||||||
name: this.initialValues?.name || "",
|
name: this.initialValues?.name || "",
|
||||||
organizationId: this.initialValues?.organizationId || this.defaultOwner,
|
organizationId: orgId || this.defaultOwner,
|
||||||
folderId: this.initialValues?.folderId || null,
|
folderId: this.initialValues?.folderId || null,
|
||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
favorite: false,
|
favorite: false,
|
||||||
});
|
});
|
||||||
await this.updateCollectionOptions(this.initialValues?.collectionIds || []);
|
await this.updateCollectionOptions(this.initialValues?.collectionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.allowOwnershipChange) {
|
if (!this.allowOwnershipChange) {
|
||||||
this.itemDetailsForm.controls.organizationId.disable();
|
this.itemDetailsForm.controls.organizationId.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.itemDetailsForm.controls.organizationId.valueChanges
|
this.itemDetailsForm.controls.organizationId.valueChanges
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
concatMap(async () => {
|
concatMap(async () => await this.updateCollectionOptions()),
|
||||||
await this.updateCollectionOptions();
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the default collection IDs for the selected organization.
|
||||||
|
* Returns null if any of the following apply:
|
||||||
|
* - the feature flag is disabled
|
||||||
|
* - no org is currently selected
|
||||||
|
* - the selected org doesn't have the "no private data policy" enabled
|
||||||
|
*/
|
||||||
|
private async getDefaultCollectionId(orgId?: OrganizationId) {
|
||||||
|
if (!orgId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isFeatureEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.CreateDefaultLocation,
|
||||||
|
);
|
||||||
|
if (!isFeatureEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
|
const selectedOrgHasPolicyEnabled = (
|
||||||
|
await firstValueFrom(
|
||||||
|
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId),
|
||||||
|
)
|
||||||
|
).find((p) => p.organizationId);
|
||||||
|
if (!selectedOrgHasPolicyEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultUserCollection = this.collections.find(
|
||||||
|
(c) => c.organizationId === orgId && c.type === CollectionTypes.DefaultUserCollection,
|
||||||
|
);
|
||||||
|
// If the user was added after the policy was enabled as they will not have any private data
|
||||||
|
// and will not have a default collection.
|
||||||
|
return defaultUserCollection?.id;
|
||||||
|
}
|
||||||
|
|
||||||
private async initFromExistingCipher(prefillCipher: CipherView) {
|
private async initFromExistingCipher(prefillCipher: CipherView) {
|
||||||
const { name, folderId, collectionIds } = prefillCipher;
|
const { name, folderId, collectionIds } = prefillCipher;
|
||||||
|
|
||||||
@@ -332,6 +369,11 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
// Non-admins can only select assigned collections that are not read only. (Non-AC)
|
// Non-admins can only select assigned collections that are not read only. (Non-AC)
|
||||||
return c.assigned && !c.readOnly;
|
return c.assigned && !c.readOnly;
|
||||||
})
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
||||||
|
const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
||||||
|
return aIsDefaultCollection - bIsDefaultCollection;
|
||||||
|
})
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -349,10 +391,17 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startingSelection.length > 0) {
|
if (startingSelection.filter(Boolean).length > 0) {
|
||||||
collectionsControl.setValue(
|
collectionsControl.setValue(
|
||||||
this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)),
|
this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const defaultCollectionId = await this.getDefaultCollectionId(orgId);
|
||||||
|
if (defaultCollectionId) {
|
||||||
|
collectionsControl.setValue(
|
||||||
|
this.collectionOptions.filter((c) => c.id === defaultCollectionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user