mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
// FIXME: Update this file to be type safe and remove this and next line
|
|
// @ts-strict-ignore
|
|
import { CommonModule } from "@angular/common";
|
|
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
|
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
|
|
|
// 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
|
|
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|
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 { 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 { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
|
import {
|
|
CardComponent,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
SectionHeaderComponent,
|
|
SelectItemView,
|
|
SelectModule,
|
|
TypographyModule,
|
|
} from "@bitwarden/components";
|
|
|
|
import {
|
|
CipherFormConfig,
|
|
OptionalInitialValues,
|
|
} from "../../abstractions/cipher-form-config.service";
|
|
import { CipherFormContainer } from "../../cipher-form-container";
|
|
|
|
@Component({
|
|
selector: "vault-item-details-section",
|
|
templateUrl: "./item-details-section.component.html",
|
|
imports: [
|
|
CardComponent,
|
|
TypographyModule,
|
|
FormFieldModule,
|
|
ReactiveFormsModule,
|
|
SelectModule,
|
|
SectionHeaderComponent,
|
|
IconButtonModule,
|
|
JslibModule,
|
|
CommonModule,
|
|
],
|
|
})
|
|
export class ItemDetailsSectionComponent implements OnInit {
|
|
itemDetailsForm = this.formBuilder.group({
|
|
name: ["", [Validators.required]],
|
|
organizationId: [null],
|
|
folderId: [null],
|
|
collectionIds: new FormControl([], [Validators.required]),
|
|
favorite: [false],
|
|
});
|
|
|
|
/**
|
|
* Collection options available for the selected organization.
|
|
* @protected
|
|
*/
|
|
protected collectionOptions: SelectItemView[] = [];
|
|
|
|
/**
|
|
* Collections that are already assigned to the cipher and are read-only. These cannot be removed.
|
|
* @protected
|
|
*/
|
|
protected readOnlyCollections: CollectionView[] = [];
|
|
|
|
protected showCollectionsControl: boolean;
|
|
|
|
/** The email address associated with the active account */
|
|
protected userEmail$ = this.accountService.activeAccount$.pipe(map((account) => account.email));
|
|
|
|
protected organizations: Organization[] = [];
|
|
|
|
protected userId: UserId;
|
|
|
|
protected favoriteButtonDisabled = false;
|
|
|
|
@Input({ required: true })
|
|
config: CipherFormConfig;
|
|
|
|
@Input()
|
|
originalCipherView: CipherView;
|
|
|
|
get readOnlyCollectionsNames(): string[] {
|
|
return this.readOnlyCollections.map((c) => c.name);
|
|
}
|
|
/**
|
|
* Whether the form is in partial edit mode. Only the folder and favorite controls are available.
|
|
*/
|
|
get partialEdit(): boolean {
|
|
return this.config.mode === "partial-edit";
|
|
}
|
|
|
|
get allowPersonalOwnership() {
|
|
return this.config.organizationDataOwnershipDisabled;
|
|
}
|
|
|
|
get collections(): CollectionView[] {
|
|
return this.config.collections;
|
|
}
|
|
|
|
get initialValues(): OptionalInitialValues | undefined {
|
|
return this.config.initialValues;
|
|
}
|
|
|
|
/**
|
|
* Show the personal ownership option in the Owner dropdown when any of the following:
|
|
* - personal ownership is allowed
|
|
* - `organizationId` control is disabled
|
|
* - personal ownership is not allowed AND the user is editing a cipher that is not
|
|
* currently owned by an organization
|
|
*/
|
|
get showPersonalOwnershipOption() {
|
|
return (
|
|
this.allowPersonalOwnership ||
|
|
this.itemDetailsForm.controls.organizationId.disabled ||
|
|
(!this.allowPersonalOwnership &&
|
|
this.config.originalCipher &&
|
|
this.itemDetailsForm.controls.organizationId.value == null)
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
private cipherFormContainer: CipherFormContainer,
|
|
private formBuilder: FormBuilder,
|
|
private i18nService: I18nService,
|
|
private destroyRef: DestroyRef,
|
|
private accountService: AccountService,
|
|
private configService: ConfigService,
|
|
private policyService: PolicyService,
|
|
) {
|
|
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
|
this.itemDetailsForm.valueChanges
|
|
.pipe(
|
|
takeUntilDestroyed(),
|
|
// getRawValue() because organizationId can be disabled for edit mode
|
|
map(() => this.itemDetailsForm.getRawValue()),
|
|
)
|
|
.subscribe((value) => {
|
|
this.cipherFormContainer.patchCipher((cipher) => {
|
|
Object.assign(cipher, {
|
|
name: value.name,
|
|
organizationId: value.organizationId,
|
|
folderId: value.folderId,
|
|
collectionIds: [
|
|
...(value.collectionIds?.map((c) => c.id) || []),
|
|
...this.readOnlyCollections.map((c) => c.id),
|
|
],
|
|
favorite: value.favorite,
|
|
} as CipherView);
|
|
return cipher;
|
|
});
|
|
});
|
|
}
|
|
|
|
get favoriteIcon() {
|
|
return this.itemDetailsForm.controls.favorite.value ? "bwi-star-f" : "bwi-star";
|
|
}
|
|
|
|
toggleFavorite() {
|
|
this.itemDetailsForm.controls.favorite.setValue(!this.itemDetailsForm.controls.favorite.value);
|
|
}
|
|
|
|
get allowOwnershipChange() {
|
|
// Do not allow ownership change in edit mode and the cipher is owned by an organization
|
|
if (this.config.mode === "edit" && this.originalCipherView?.organizationId != null) {
|
|
return false;
|
|
}
|
|
|
|
// If personal ownership is allowed and there is at least one organization, allow ownership change.
|
|
if (this.allowPersonalOwnership) {
|
|
return this.organizations.length > 0;
|
|
}
|
|
|
|
// Personal ownership is not allowed, only allow ownership change if there is more than one organization.
|
|
return this.organizations.length > 1;
|
|
}
|
|
|
|
get showOwnership() {
|
|
// Don't show ownership field for archived ciphers
|
|
if (this.originalCipherView?.isArchived) {
|
|
return false;
|
|
}
|
|
|
|
// Show ownership field when editing with available orgs
|
|
const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit";
|
|
|
|
// When in admin console, ownership should not be shown unless cloning
|
|
const isAdminConsoleEdit = this.config.isAdminConsole && this.config.mode !== "clone";
|
|
|
|
return this.allowOwnershipChange || (isEditingWithOrgs && !isAdminConsoleEdit);
|
|
}
|
|
|
|
get defaultOwner() {
|
|
return this.allowPersonalOwnership ? null : this.organizations[0].id;
|
|
}
|
|
|
|
async ngOnInit() {
|
|
this.organizations = this.config.organizations.sort(
|
|
Utils.getSortFunction(this.i18nService, "name"),
|
|
);
|
|
|
|
this.userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
|
|
|
if (!this.allowPersonalOwnership && this.organizations.length === 0) {
|
|
throw new Error("No organizations available for ownership.");
|
|
}
|
|
|
|
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
|
|
|
if (prefillCipher) {
|
|
await this.initFromExistingCipher(prefillCipher);
|
|
} else {
|
|
const orgId = this.initialValues?.organizationId;
|
|
this.itemDetailsForm.setValue({
|
|
name: this.initialValues?.name || "",
|
|
organizationId: orgId || this.defaultOwner,
|
|
folderId: this.initialValues?.folderId || null,
|
|
collectionIds: [],
|
|
favorite: false,
|
|
});
|
|
await this.updateCollectionOptions(this.initialValues?.collectionIds);
|
|
}
|
|
|
|
this.setFormState();
|
|
|
|
this.itemDetailsForm.controls.organizationId.valueChanges
|
|
.pipe(
|
|
takeUntilDestroyed(this.destroyRef),
|
|
distinctUntilChanged(),
|
|
concatMap(async () => {
|
|
await this.updateCollectionOptions();
|
|
this.setFormState();
|
|
}),
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
/**
|
|
* Updates the global form and organizationId control states.
|
|
*/
|
|
private setFormState() {
|
|
if (this.config.originalCipher && !this.allowPersonalOwnership) {
|
|
// When editing a cipher and the user cannot have personal ownership
|
|
// and the cipher is is not within the organization - force the user to
|
|
// move the cipher within the organization first before editing any other field
|
|
if (this.itemDetailsForm.controls.organizationId.value == null) {
|
|
this.cipherFormContainer.disableFormFields();
|
|
this.itemDetailsForm.controls.organizationId.enable();
|
|
this.favoriteButtonDisabled = true;
|
|
} else {
|
|
// The "after" from the above: When editing a cipher and the user cannot have personal ownership
|
|
// and the organization is populated - re-enable the global form.
|
|
this.cipherFormContainer.enableFormFields();
|
|
this.favoriteButtonDisabled = false;
|
|
this.setCollectionControlState();
|
|
}
|
|
} else if (!this.allowOwnershipChange) {
|
|
// When the user cannot change the organization field, disable the organizationId control.
|
|
// This could be because they aren't a part of an organization
|
|
this.itemDetailsForm.controls.organizationId.disable({ emitEvent: false });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the default collection IDs for the selected organization.
|
|
* Returns null if any of the following apply:
|
|
* - the feature flag is disabled
|
|
* - the "no private data policy" doesn't apply to the user
|
|
* - no org is currently selected
|
|
* - the selected org doesn't have the "no private data policy" enabled
|
|
*/
|
|
private async getDefaultCollectionId(orgId?: OrganizationId) {
|
|
if (!orgId || this.allowPersonalOwnership) {
|
|
return;
|
|
}
|
|
|
|
const isFeatureEnabled = await this.configService.getFeatureFlag(
|
|
FeatureFlag.CreateDefaultLocation,
|
|
);
|
|
|
|
if (!isFeatureEnabled) {
|
|
return;
|
|
}
|
|
|
|
const selectedOrgHasPolicyEnabled = (
|
|
await firstValueFrom(
|
|
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, this.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) {
|
|
const { name, folderId, collectionIds } = prefillCipher;
|
|
|
|
this.itemDetailsForm.patchValue({
|
|
name: name ? name : (this.initialValues?.name ?? ""),
|
|
// We do not allow changing ownership of an existing cipher.
|
|
// Angular forms do not support `undefined` as a value for a form control,
|
|
// force `null` if `organizationId` is undefined.
|
|
organizationId: prefillCipher.organizationId ?? null,
|
|
folderId: folderId ? folderId : (this.initialValues?.folderId ?? null),
|
|
collectionIds: [],
|
|
favorite: prefillCipher.favorite,
|
|
});
|
|
|
|
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
|
const initializedWithCachedCipher = this.cipherFormContainer.initializedWithCachedCipher();
|
|
|
|
// Configure form for clone mode.
|
|
if (this.config.mode === "clone") {
|
|
if (!initializedWithCachedCipher) {
|
|
this.itemDetailsForm.controls.name.setValue(
|
|
prefillCipher.name + " - " + this.i18nService.t("clone"),
|
|
);
|
|
}
|
|
|
|
if (!this.allowPersonalOwnership && prefillCipher.organizationId == null) {
|
|
this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner);
|
|
}
|
|
}
|
|
|
|
const prefillCollections = collectionIds?.length
|
|
? (collectionIds as CollectionId[])
|
|
: (this.initialValues?.collectionIds ?? []);
|
|
|
|
await this.updateCollectionOptions(prefillCollections);
|
|
|
|
this.setCollectionControlState();
|
|
|
|
if (this.partialEdit) {
|
|
this.itemDetailsForm.disable();
|
|
this.itemDetailsForm.controls.favorite.enable();
|
|
this.itemDetailsForm.controls.folderId.enable();
|
|
} else if (this.config.mode === "edit") {
|
|
if (!this.config.isAdminConsole || !this.config.admin) {
|
|
this.readOnlyCollections = this.collections.filter(
|
|
// When the configuration is set up for admins, they can alter read only collections
|
|
(c) =>
|
|
c.organizationId === orgId &&
|
|
c.readOnly &&
|
|
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private setCollectionControlState() {
|
|
const initialCipherView = this.cipherFormContainer.getInitialCipherView();
|
|
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
|
const organization = this.organizations.find((o) => o.id === orgId);
|
|
if (!organization || !initialCipherView) {
|
|
return;
|
|
}
|
|
// Disable the collection control if either of the following apply:
|
|
// 1. The organization does not allow editing all ciphers and the existing cipher cannot be assigned to
|
|
// collections
|
|
// 2. When Owners/Admins access setting is turned on.
|
|
// AND either:
|
|
// a. Disable Collections Options if Owner/Admin does not have Edit/Manage permissions on item
|
|
// b. Disable Collections Options if Custom user does not have Edit/Manage permissions on item
|
|
if (
|
|
(!organization.canEditAllCiphers && !initialCipherView.canAssignToCollections) ||
|
|
(organization.allowAdminAccessToAllCollectionItems &&
|
|
(!initialCipherView.viewPassword || !initialCipherView.edit)) ||
|
|
(organization.type === OrganizationUserType.Custom && !initialCipherView.viewPassword)
|
|
) {
|
|
this.itemDetailsForm.controls.collectionIds.disable();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the collection options based on the selected organization.
|
|
* @param startingSelection - Optional starting selection of collectionIds to be automatically selected.
|
|
* @private
|
|
*/
|
|
private async updateCollectionOptions(startingSelection: CollectionId[] = []) {
|
|
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
|
const collectionsControl = this.itemDetailsForm.controls.collectionIds;
|
|
|
|
// No organization selected, disable/hide the collections control.
|
|
if (orgId == null) {
|
|
this.collectionOptions = [];
|
|
collectionsControl.reset();
|
|
collectionsControl.disable();
|
|
this.showCollectionsControl = false;
|
|
return;
|
|
} else {
|
|
collectionsControl.enable();
|
|
this.showCollectionsControl = true;
|
|
}
|
|
|
|
/**
|
|
* Determine if the the cipher is only assigned to shared collections.
|
|
* i.e. The cipher is not assigned to a default collections.
|
|
* Note: `.every` will return true for an empty array
|
|
*/
|
|
const cipherIsOnlyInOrgCollections =
|
|
(this.originalCipherView?.collectionIds ?? []).length > 0 &&
|
|
this.originalCipherView.collectionIds.every(
|
|
(cId) =>
|
|
this.collections.find((c) => c.id === cId)?.type === CollectionTypes.SharedCollection,
|
|
);
|
|
this.collectionOptions = this.collections
|
|
.filter((c) => {
|
|
// The collection belongs to the organization
|
|
if (c.organizationId !== orgId) {
|
|
return false;
|
|
}
|
|
|
|
// When in partial edit mode, show all org collections because the control is disabled.
|
|
if (this.partialEdit) {
|
|
return true;
|
|
}
|
|
|
|
// When viewing as an admin, all collections should be shown, even readonly. (AC Only)
|
|
if (this.config.admin) {
|
|
return true;
|
|
}
|
|
|
|
// When the cipher is only assigned to shared collections, do not allow a user to
|
|
// move it back to a default collection. Exclude the default collection from the list.
|
|
if (cipherIsOnlyInOrgCollections && c.type === CollectionTypes.DefaultUserCollection) {
|
|
return false;
|
|
}
|
|
|
|
// Non-admins can only select assigned collections that are not read only. (Non-AC)
|
|
return c.assigned && !c.readOnly;
|
|
})
|
|
.sort((a, b) => {
|
|
// Show default collection first
|
|
const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
|
const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
|
return aIsDefaultCollection - bIsDefaultCollection;
|
|
})
|
|
.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
listName: c.name,
|
|
labelName: c.name,
|
|
}));
|
|
|
|
collectionsControl.reset();
|
|
collectionsControl.enable();
|
|
this.showCollectionsControl = true;
|
|
|
|
// If there is only one collection, select it by default.
|
|
if (this.collectionOptions.length === 1) {
|
|
collectionsControl.setValue(this.collectionOptions);
|
|
return;
|
|
}
|
|
|
|
if (startingSelection.filter(Boolean).length > 0) {
|
|
collectionsControl.setValue(
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|