diff --git a/.storybook/main.ts b/.storybook/main.ts index 26eee201f9..175ed33948 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -6,6 +6,8 @@ const config: StorybookConfig = { stories: [ "../libs/auth/src/**/*.mdx", "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/vault/src/**/*.mdx", + "../libs/vault/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/components/src/**/*.mdx", "../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)", "../apps/web/src/**/*.mdx", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0e8ffb1151..95db94832b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3492,9 +3492,31 @@ "itemsWithNoFolder": { "message": "Items with no folder" }, + "itemDetails": { + "message": "Item details" + }, + "itemName": { + "message": "Item name" + }, + "cannotRemoveViewOnlyCollections": { + "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "placeholders": { + "collections": { + "content": "$1", + "example": "Work, Personal" + } + } + }, "organizationIsDeactivated": { "message": "Organization is deactivated" }, + "owner": { + "message": "Owner" + }, + "selfOwnershipLabel": { + "message": "You", + "description": "Used as a label to indicate that the user is the owner of an item." + }, "contactYourOrgAdmin": { "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 69e6d36afa..4b28444f9b 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -323,12 +323,11 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "appearance" }, }, - { + ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "clone-cipher", - component: AddEditComponent, canActivate: [AuthGuard], data: { state: "clone-cipher" }, - }, + }), { path: "send-type", component: SendTypeComponent, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index c6fd12b249..4d8461a57c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -1,10 +1,21 @@ - + + + - diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index a4af53fe45..f447c0864d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,24 +1,86 @@ -import { CommonModule } from "@angular/common"; +import { CommonModule, Location } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Params } from "@angular/router"; +import { map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { SearchModule, ButtonModule } from "@bitwarden/components"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components"; +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, + CipherFormModule, + DefaultCipherFormConfigService, +} from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component"; +/** + * Helper class to parse query parameters for the AddEdit route. + */ +class QueryParams { + constructor(params: Params) { + this.cipherId = params.cipherId; + this.type = parseInt(params.type, null); + this.clone = params.clone === "true"; + this.folderId = params.folderId; + this.organizationId = params.organizationId; + this.collectionId = params.collectionId; + this.uri = params.uri; + } + + /** + * The ID of the cipher to edit or clone. + */ + cipherId?: CipherId; + + /** + * The type of cipher to create. + */ + type: CipherType; + + /** + * Whether to clone the cipher. + */ + clone?: boolean; + + /** + * Optional folderId to pre-select. + */ + folderId?: string; + + /** + * Optional organizationId to pre-select. + */ + organizationId?: OrganizationId; + + /** + * Optional collectionId to pre-select. + */ + collectionId?: CollectionId; + + /** + * Optional URI to pre-fill for login ciphers. + */ + uri?: string; +} + +export type AddEditQueryParams = Partial>; + @Component({ selector: "app-add-edit-v2", templateUrl: "add-edit-v2.component.html", standalone: true, + providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], imports: [ CommonModule, SearchModule, @@ -29,33 +91,86 @@ import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-a PopupPageComponent, PopupHeaderComponent, PopupFooterComponent, + CipherFormModule, + AsyncActionsModule, ], }) export class AddEditV2Component { headerText: string; - cipherId: CipherId; - isEdit: boolean = false; + config: CipherFormConfig; + + get loading() { + return this.config == null; + } + + get originalCipherId(): CipherId | null { + return this.config?.originalCipher.id as CipherId; + } constructor( private route: ActivatedRoute, + private location: Location, private i18nService: I18nService, + private addEditFormConfigService: CipherFormConfigService, ) { this.subscribeToParams(); } - subscribeToParams(): void { - this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => { - const isNew = params.isNew?.toLowerCase() === "true"; - const cipherType = parseInt(params.type); - - this.isEdit = !isNew; - this.cipherId = params.cipherId; - this.headerText = this.setHeader(isNew, cipherType); - }); + onCipherSaved(savedCipher: CipherView) { + this.location.back(); } - setHeader(isNew: boolean, type: CipherType) { - const partOne = isNew ? "newItemHeader" : "editItemHeader"; + subscribeToParams(): void { + this.route.queryParams + .pipe( + takeUntilDestroyed(), + map((params) => new QueryParams(params)), + switchMap(async (params) => { + let mode: CipherFormMode; + if (params.cipherId == null) { + mode = "add"; + } else { + mode = params.clone ? "clone" : "edit"; + } + const config = await this.addEditFormConfigService.buildConfig( + mode, + params.cipherId, + params.type, + ); + + if (config.mode === "edit" && !config.originalCipher.edit) { + config.mode = "partial-edit"; + } + + this.setInitialValuesFromParams(params, config); + + return config; + }), + ) + .subscribe((config) => { + this.config = config; + this.headerText = this.setHeader(config.mode, config.cipherType); + }); + } + + setInitialValuesFromParams(params: QueryParams, config: CipherFormConfig) { + config.initialValues = {}; + if (params.folderId) { + config.initialValues.folderId = params.folderId; + } + if (params.organizationId) { + config.initialValues.organizationId = params.organizationId; + } + if (params.collectionId) { + config.initialValues.collectionIds = [params.collectionId]; + } + if (params.uri) { + config.initialValues.loginUri = params.uri; + } + } + + setHeader(mode: CipherFormMode, type: CipherType) { + const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; switch (type) { case CipherType.Login: diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 836d4cbbd1..23ff959309 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -19,6 +19,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; @Component({ standalone: true, @@ -145,9 +146,10 @@ export class ItemMoreOptionsComponent { await this.router.navigate(["/clone-cipher"], { queryParams: { - cloneMode: true, + clone: true.toString(), cipherId: this.cipher.id, - }, + type: this.cipher.type.toString(), + } as AddEditQueryParams, }); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index e90afec538..65456fd74a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -1,10 +1,19 @@ import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, Input } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components"; +import { ButtonModule, MenuModule, NoItemsModule } from "@bitwarden/components"; + +import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; + +export interface NewItemInitialValues { + folderId?: string; + organizationId?: OrganizationId; + collectionId?: CollectionId; +} @Component({ selector: "app-new-item-dropdown", @@ -12,17 +21,27 @@ import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components"; standalone: true, imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], }) -export class NewItemDropdownV2Component implements OnInit, OnDestroy { +export class NewItemDropdownV2Component { cipherType = CipherType; + /** + * Optional initial values to pass to the add cipher form + */ + @Input() + initialValues: NewItemInitialValues; + constructor(private router: Router) {} - ngOnInit(): void {} + private buildQueryParams(type: CipherType): AddEditQueryParams { + return { + type: type.toString(), + collectionId: this.initialValues?.collectionId, + organizationId: this.initialValues?.organizationId, + folderId: this.initialValues?.folderId, + }; + } - ngOnDestroy(): void {} - - // TODO PM-6826: add selectedVault query param newItemNavigate(type: CipherType) { - void this.router.navigate(["/add-cipher"], { queryParams: { type: type, isNew: true } }); + void this.router.navigate(["/add-cipher"], { queryParams: this.buildQueryParams(type) }); } } diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index b97fa38eea..f666bc09a8 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -1,7 +1,7 @@ - + @@ -15,7 +15,10 @@ {{ "yourVaultIsEmpty" | i18n }} {{ "autofillSuggestionsTip" | i18n }} - + diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts index 14df62de12..777e44f0e1 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts @@ -2,9 +2,10 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { RouterLink } from "@angular/router"; -import { combineLatest } from "rxjs"; +import { combineLatest, map, Observable, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; @@ -13,8 +14,12 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; +import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2"; -import { NewItemDropdownV2Component } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component"; +import { + NewItemDropdownV2Component, + NewItemInitialValues, +} from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component"; import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component"; import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component"; @@ -50,6 +55,17 @@ export class VaultV2Component implements OnInit, OnDestroy { protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; + protected newItemItemValues$: Observable = + this.vaultPopupListFiltersService.filters$.pipe( + map((filter) => ({ + organizationId: (filter.organization?.id || + filter.collection?.organizationId) as OrganizationId, + collectionId: filter.collection?.id as CollectionId, + folderId: filter.folder?.id, + })), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + /** Visual state of the vault */ protected vaultState: VaultState | null = null; @@ -59,7 +75,10 @@ export class VaultV2Component implements OnInit, OnDestroy { protected VaultStateEnum = VaultState; - constructor(private vaultPopupItemsService: VaultPopupItemsService) { + constructor( + private vaultPopupItemsService: VaultPopupItemsService, + private vaultPopupListFiltersService: VaultPopupListFiltersService, + ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, this.vaultPopupItemsService.noFilteredResults$, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 44b05cf26e..8462697973 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -173,6 +173,10 @@ "message": "No folder", "description": "This is the folder for uncategorized items" }, + "selfOwnershipLabel": { + "message": "You", + "description": "Used as a label to indicate that the user is the owner of an item." + }, "addFolder": { "message": "Add folder" }, @@ -401,6 +405,21 @@ "item": { "message": "Item" }, + "itemDetails": { + "message": "Item details" + }, + "itemName": { + "message": "Item name" + }, + "cannotRemoveViewOnlyCollections": { + "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "placeholders": { + "collections": { + "content": "$1", + "example": "Work, Personal" + } + } + }, "ex": { "message": "ex.", "description": "Short abbreviation for 'example'." @@ -4159,7 +4178,7 @@ }, "ssoHandOff": { "message": "You may now close this tab and continue in the extension." - }, + }, "youSuccessfullyLoggedIn": { "message": "You successfully logged in" }, @@ -8494,7 +8513,7 @@ }, "billingHistoryDescription": { "message": "Download a CSV to obtain client details for each billing date. Prorated charges are not included in the CSV and may vary from the linked invoice. For the most accurate billing details, refer to your monthly invoices.", - "description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations." + "description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations." }, "monthlySubscriptionUserSeatsMessage": { "message": "Adjustments to your subscription will result in prorated charges to your billing totals on your next billing period. " diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts index 3400866719..19d0a37356 100644 --- a/libs/components/src/select/select.component.ts +++ b/libs/components/src/select/select.component.ts @@ -56,7 +56,11 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce @HostBinding("class") protected classes = ["tw-block", "tw-w-full"]; - @HostBinding() + // Usings a separate getter for the HostBinding to get around an unexplained angular error + @HostBinding("attr.disabled") + get disabledAttr() { + return this.disabled || null; + } @Input() get disabled() { return this._disabled ?? this.ngControl?.disabled ?? false; diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts new file mode 100644 index 0000000000..334fbae590 --- /dev/null +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts @@ -0,0 +1,135 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; + +/** + * The mode of the add/edit form. + * - `add` - The form is creating a new cipher. + * - `edit` - The form is editing an existing cipher. + * - `partial-edit` - The form is editing an existing cipher, but only the favorite/folder fields + * - `clone` - The form is creating a new cipher that is a clone of an existing cipher. + */ +export type CipherFormMode = "add" | "edit" | "partial-edit" | "clone"; + +/** + * Optional initial values for the form. + */ +export type OptionalInitialValues = { + folderId?: string; + organizationId?: OrganizationId; + collectionIds?: CollectionId[]; + loginUri?: string; +}; + +/** + * Base configuration object for the cipher form. Includes all common fields. + */ +type BaseCipherFormConfig = { + /** + * The mode of the form. + */ + mode: CipherFormMode; + + /** + * The type of cipher to create/edit. + */ + cipherType: CipherType; + + /** + * Flag to indicate the form should submit to admin endpoints that have different permission checks. If the + * user is not an admin or performing an action that requires admin permissions, this should be false. + */ + admin: boolean; + + /** + * Flag to indicate if the user is allowed to create ciphers in their own Vault. If false, configuration must + * supply a list of organizations that the user can create ciphers in. + */ + allowPersonalOwnership: boolean; + + /** + * The original cipher that is being edited or cloned. This can be undefined when creating a new cipher. + */ + originalCipher?: Cipher; + + /** + * Optional initial values for the form when creating a new cipher. Useful when creating a cipher in a filtered view. + */ + initialValues?: OptionalInitialValues; + + /** + * The list of collections that the user has visibility to. This list should include read-only collections as they + * can still be displayed in the component for reference. + */ + collections: CollectionView[]; + + /** + * The list of folders for the current user. Should include the "No Folder" option with a `null` id. + */ + folders: FolderView[]; + + /** + * List of organizations that the user can create ciphers for. + */ + organizations?: Organization[]; +}; + +/** + * Configuration object for the cipher form when editing/cloning an existing cipher. + */ +type ExistingCipherConfig = BaseCipherFormConfig & { + mode: "edit" | "partial-edit" | "clone"; + originalCipher: Cipher; +}; + +/** + * Configuration object for the cipher form when creating a completely new cipher. + */ +type CreateNewCipherConfig = BaseCipherFormConfig & { + mode: "add"; +}; + +type CombinedAddEditConfig = ExistingCipherConfig | CreateNewCipherConfig; + +/** + * Configuration object for the cipher form when personal ownership is allowed. + */ +type PersonalOwnershipAllowed = CombinedAddEditConfig & { + allowPersonalOwnership: true; +}; + +/** + * Configuration object for the cipher form when personal ownership is not allowed. + * Organizations must be provided. + */ +type PersonalOwnershipNotAllowed = CombinedAddEditConfig & { + allowPersonalOwnership: false; + organizations: Organization[]; +}; + +/** + * Configuration object for the cipher form. + * Determines the behavior of the form and the controls that are displayed/enabled. + */ +export type CipherFormConfig = PersonalOwnershipAllowed | PersonalOwnershipNotAllowed; + +/** + * Service responsible for building the configuration object for the cipher form. + */ +export abstract class CipherFormConfigService { + /** + * Builds the configuration for the cipher form using the specified mode, cipherId, and cipherType. + * The other configuration fields will be fetched from their respective services. + * @param mode + * @param cipherId + * @param cipherType + */ + abstract buildConfig( + mode: CipherFormMode, + cipherId?: CipherId, + cipherType?: CipherType, + ): Promise; +} diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form.service.ts new file mode 100644 index 0000000000..6d914497da --- /dev/null +++ b/libs/vault/src/cipher-form/abstractions/cipher-form.service.ts @@ -0,0 +1,22 @@ +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { CipherFormConfig } from "./cipher-form-config.service"; + +/** + * Service to save the cipher using the correct endpoint(s) and encapsulating the logic for decrypting the cipher. + * + * This service should only be used internally by the CipherFormComponent. + */ +export abstract class CipherFormService { + /** + * Helper to decrypt a cipher and avoid the need to call the cipher service directly. + * (useful for mocking tests/storybook). + */ + abstract decryptCipher(cipher: Cipher): Promise; + + /** + * Saves the new or modified cipher with the server. + */ + abstract saveCipher(cipher: CipherView, config: CipherFormConfig): Promise; +} diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts new file mode 100644 index 0000000000..2fdad061fc --- /dev/null +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -0,0 +1,28 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; + +/** + * 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 = { + itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"]; +}; + +/** + * A container for the {@link CipherForm} that allows for registration of child form groups and patching of the cipher + * to be updated/created. Child form components inject this container in order to register themselves with the parent form. + * + * This is an alternative to passing the form groups down through the component tree via @Inputs() and form updates via + * @Outputs(). It allows child forms to define their own structure and validation rules, while still being able to + * update the parent cipher. + */ +export abstract class CipherFormContainer { + abstract registerChildForm( + name: K, + group: Exclude, + ): void; + + abstract patchCipher(cipher: Partial): void; +} diff --git a/libs/vault/src/cipher-form/cipher-form.mdx b/libs/vault/src/cipher-form/cipher-form.mdx new file mode 100644 index 0000000000..ed2e799b9f --- /dev/null +++ b/libs/vault/src/cipher-form/cipher-form.mdx @@ -0,0 +1,17 @@ +import { Controls, Meta, Primary } from "@storybook/addon-docs"; + +import * as stories from "./cipher-form.stories"; + + + +# Cipher Form + +The cipher form is a re-usable form component that can be used to create, update, and clone ciphers. +It is configured via a `CipherFormConfig` object that is passed to the component as a prop. The +`CipherFormConfig` object can be created manually, or a `CipherFormConfigService` can be used to +create it. A default implementation of the `CipherFormConfigService` exists in the +`@bitwarden/vault` library. + + + + diff --git a/libs/vault/src/cipher-form/cipher-form.module.ts b/libs/vault/src/cipher-form/cipher-form.module.ts new file mode 100644 index 0000000000..3552e050c0 --- /dev/null +++ b/libs/vault/src/cipher-form/cipher-form.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from "@angular/core"; + +import { CipherFormService } from "./abstractions/cipher-form.service"; +import { CipherFormComponent } from "./components/cipher-form.component"; +import { DefaultCipherFormService } from "./services/default-cipher-form.service"; + +@NgModule({ + imports: [CipherFormComponent], + providers: [ + { + provide: CipherFormService, + useClass: DefaultCipherFormService, + }, + ], + exports: [CipherFormComponent], +}) +export class CipherFormModule {} diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts new file mode 100644 index 0000000000..47a1e90abc --- /dev/null +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -0,0 +1,188 @@ +import { importProvidersFrom } from "@angular/core"; +import { action } from "@storybook/addon-actions"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; +import { CipherFormConfig } from "@bitwarden/vault"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; + +import { CipherFormService } from "./abstractions/cipher-form.service"; +import { CipherFormModule } from "./cipher-form.module"; +import { CipherFormComponent } from "./components/cipher-form.component"; + +const defaultConfig: CipherFormConfig = { + mode: "add", + cipherType: CipherType.Login, + admin: false, + allowPersonalOwnership: true, + collections: [ + { + id: "col1", + name: "Org 1 Collection 1", + organizationId: "org1", + }, + { + id: "col2", + name: "Org 1 Collection 2", + organizationId: "org1", + }, + { + id: "colA", + name: "Org 2 Collection A", + organizationId: "org2", + }, + ] as CollectionView[], + folders: [ + { + id: undefined, + name: "No Folder", + }, + { + id: "folder2", + name: "Folder 2", + }, + ] as FolderView[], + organizations: [ + { + id: "org1", + name: "Organization 1", + }, + { + id: "org2", + name: "Organization 2", + }, + ] as Organization[], + originalCipher: { + id: "123", + organizationId: "org1", + name: "Test Cipher", + folderId: "folder2", + collectionIds: ["col1"], + favorite: false, + } as unknown as Cipher, +}; + +class TestAddEditFormService implements CipherFormService { + decryptCipher(): Promise { + return Promise.resolve(defaultConfig.originalCipher as any); + } + async saveCipher(cipher: CipherView): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return cipher; + } +} + +const actionsData = { + onSave: action("onSave"), +}; + +export default { + title: "Vault/Cipher Form", + component: CipherFormComponent, + decorators: [ + moduleMetadata({ + imports: [CipherFormModule, AsyncActionsModule, ButtonModule], + providers: [ + { + provide: CipherFormService, + useClass: TestAddEditFormService, + }, + { + provide: ToastService, + useValue: { + showToast: action("showToast"), + }, + }, + ], + }), + componentWrapperDecorator( + (story) => `
${story}
`, + ), + applicationConfig({ + providers: [importProvidersFrom(PreloadedEnglishI18nModule)], + }), + ], + args: { + config: defaultConfig, + }, + argTypes: { + config: { + description: "The configuration object for the form.", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + return { + props: { + onSave: actionsData.onSave, + ...args, + }, + template: /*html*/ ` + + + `, + }; + }, +}; + +export const Edit: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "edit", + originalCipher: defaultConfig.originalCipher, + }, + }, +}; + +export const PartialEdit: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "partial-edit", + originalCipher: defaultConfig.originalCipher, + }, + }, +}; + +export const Clone: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "clone", + originalCipher: defaultConfig.originalCipher, + }, + }, +}; + +export const NoPersonalOwnership: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "add", + allowPersonalOwnership: false, + originalCipher: defaultConfig.originalCipher, + organizations: defaultConfig.organizations, + }, + }, +}; diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html new file mode 100644 index 0000000000..78b8278ddf --- /dev/null +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -0,0 +1,14 @@ +
+ + + + + + + + + +
diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts new file mode 100644 index 0000000000..b307550803 --- /dev/null +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -0,0 +1,212 @@ +import { NgIf } from "@angular/common"; +import { + AfterViewInit, + Component, + DestroyRef, + EventEmitter, + forwardRef, + inject, + Input, + OnChanges, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + AsyncActionsModule, + BitSubmitDirective, + ButtonComponent, + CardComponent, + FormFieldModule, + ItemModule, + SectionComponent, + SelectModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; +import { CipherFormService } from "../abstractions/cipher-form.service"; +import { CipherForm, CipherFormContainer } from "../cipher-form-container"; + +import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; + +@Component({ + selector: "vault-cipher-form", + templateUrl: "./cipher-form.component.html", + standalone: true, + providers: [ + { + provide: CipherFormContainer, + useExisting: forwardRef(() => CipherFormComponent), + }, + ], + imports: [ + AsyncActionsModule, + CardComponent, + SectionComponent, + TypographyModule, + ItemModule, + FormFieldModule, + ReactiveFormsModule, + SelectModule, + ItemDetailsSectionComponent, + NgIf, + ], +}) +export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { + @ViewChild(BitSubmitDirective) + private bitSubmit: BitSubmitDirective; + private destroyRef = inject(DestroyRef); + private _firstInitialized = false; + + /** + * The form ID to use for the form. Used to connect it to a submit button. + */ + @Input({ required: true }) formId: string; + + /** + * The configuration for the add/edit form. Used to determine which controls are shown and what values are available. + */ + @Input({ required: true }) config: CipherFormConfig; + + /** + * Optional submit button that will be disabled or marked as loading when the form is submitting. + */ + @Input() + submitBtn?: ButtonComponent; + + /** + * Event emitted when the cipher is saved successfully. + */ + @Output() cipherSaved = new EventEmitter(); + + /** + * The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method. + * @protected + */ + protected cipherForm = this.formBuilder.group({}); + + /** + * The original cipher being edited or cloned. Null for add mode. + * @protected + */ + protected originalCipherView: CipherView | null; + + /** + * The value of the updated cipher. Starts as a new cipher (or clone of originalCipher) and is updated + * by child components via the `patchCipher` method. + * @protected + */ + protected updatedCipherView: CipherView | null; + protected loading: boolean = true; + + ngAfterViewInit(): void { + if (this.submitBtn) { + this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { + this.submitBtn.loading = loading; + }); + + this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => { + this.submitBtn.disabled = disabled; + }); + } + } + + /** + * Registers a child form group with the parent form group. Used by child components to add their form groups to + * the parent form for validation. + * @param name - The name of the form group. + * @param group - The form group to add. + */ + registerChildForm( + name: K, + group: Exclude, + ): void { + this.cipherForm.setControl(name, group); + } + + /** + * Patches the updated cipher with the provided partial cipher. Used by child components to update the cipher + * as their form values change. + * @param cipher + */ + patchCipher(cipher: Partial): void { + this.updatedCipherView = Object.assign(this.updatedCipherView, cipher); + } + + /** + * We need to re-initialize the form when the config is updated. + */ + async ngOnChanges() { + // Avoid re-initializing the form on the first change detection cycle. + if (this._firstInitialized) { + await this.init(); + } + } + + async ngOnInit() { + await this.init(); + this._firstInitialized = true; + } + + async init() { + this.loading = true; + this.updatedCipherView = new CipherView(); + this.originalCipherView = null; + this.cipherForm.reset(); + + if (this.config == null) { + return; + } + + if (this.config.mode !== "add") { + if (this.config.originalCipher == null) { + throw new Error("Original cipher is required for edit or clone mode"); + } + + this.originalCipherView = await this.addEditFormService.decryptCipher( + this.config.originalCipher, + ); + + this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView); + } else { + this.updatedCipherView.type = this.config.cipherType; + } + + this.loading = false; + } + + constructor( + private formBuilder: FormBuilder, + private addEditFormService: CipherFormService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + submit = async () => { + if (this.cipherForm.invalid) { + this.cipherForm.markAllAsTouched(); + return; + } + + await this.addEditFormService.saveCipher(this.updatedCipherView, this.config); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t( + this.config.mode === "edit" || this.config.mode === "partial-edit" + ? "editedItem" + : "addedItem", + ), + }); + + this.cipherSaved.emit(this.updatedCipherView); + }; +} diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html new file mode 100644 index 0000000000..05286eaaa3 --- /dev/null +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html @@ -0,0 +1,61 @@ + + +

{{ "itemDetails" | i18n }}

+ +
+ + + {{ "itemName" | i18n }} + + +
+ + {{ "owner" | i18n }} + + + + + + + {{ "folder" | i18n }} + + + + +
+ + + {{ "collections" | i18n }} + + + {{ "cannotRemoveViewOnlyCollections" | i18n: readOnlyCollections.join(", ") }} + + + +
+
diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts new file mode 100644 index 0000000000..8ec42f807a --- /dev/null +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -0,0 +1,355 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; + +import { CipherFormConfig } from "../../abstractions/cipher-form-config.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { ItemDetailsSectionComponent } from "./item-details-section.component"; + +describe("ItemDetailsSectionComponent", () => { + let component: ItemDetailsSectionComponent; + let fixture: ComponentFixture; + let cipherFormProvider: MockProxy; + let i18nService: MockProxy; + + beforeEach(async () => { + cipherFormProvider = mock(); + i18nService = mock(); + + await TestBed.configureTestingModule({ + imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule], + providers: [ + { provide: CipherFormContainer, useValue: cipherFormProvider }, + { provide: I18nService, useValue: i18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ItemDetailsSectionComponent); + component = fixture.componentInstance; + component.config = { + collections: [], + organizations: [], + folders: [], + } as CipherFormConfig; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + it("should throw an error if no organizations are available for ownership and personal ownership is not allowed", async () => { + component.config.allowPersonalOwnership = false; + component.config.organizations = []; + await expect(component.ngOnInit()).rejects.toThrow( + "No organizations available for ownership.", + ); + }); + + it("should initialize form with default values if no originalCipher is provided", fakeAsync(async () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + await component.ngOnInit(); + tick(); + expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({ + name: "", + organizationId: null, + folderId: null, + collectionIds: [], + favorite: false, + }); + })); + + it("should initialize form with values from originalCipher if provided", fakeAsync(async () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1"], + favorite: true, + } as CipherView; + + await component.ngOnInit(); + tick(); + + expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({ + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1"], + favorite: true, + }); + })); + + it("should disable organizationId control if ownership change is not allowed", async () => { + component.config.allowPersonalOwnership = false; + component.config.organizations = [{ id: "org1" } as Organization]; + jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true); + }); + }); + + describe("toggleFavorite", () => { + it("should toggle the favorite control value", () => { + component.itemDetailsForm.controls.favorite.setValue(false); + component.toggleFavorite(); + expect(component.itemDetailsForm.controls.favorite.value).toBe(true); + component.toggleFavorite(); + expect(component.itemDetailsForm.controls.favorite.value).toBe(false); + }); + }); + + describe("favoriteIcon", () => { + it("should return the correct icon based on favorite value", () => { + component.itemDetailsForm.controls.favorite.setValue(false); + expect(component.favoriteIcon).toBe("bwi-star"); + component.itemDetailsForm.controls.favorite.setValue(true); + expect(component.favoriteIcon).toBe("bwi-star-f"); + }); + }); + + describe("allowOwnershipChange", () => { + it("should not allow ownership change in edit mode", () => { + component.config.mode = "edit"; + expect(component.allowOwnershipChange).toBe(false); + }); + + it("should allow ownership change if personal ownership is allowed and there is at least one organization", () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + expect(component.allowOwnershipChange).toBe(true); + }); + + it("should allow ownership change if personal ownership is not allowed but there is more than one organization", () => { + component.config.allowPersonalOwnership = false; + component.config.organizations = [ + { id: "org1" } as Organization, + { id: "org2" } as Organization, + ]; + expect(component.allowOwnershipChange).toBe(true); + }); + }); + + describe("defaultOwner", () => { + it("should return null if personal ownership is allowed", () => { + component.config.allowPersonalOwnership = true; + expect(component.defaultOwner).toBeNull(); + }); + + it("should return the first organization id if personal ownership is not allowed", () => { + component.config.allowPersonalOwnership = false; + component.config.organizations = [{ id: "org1" } as Organization]; + expect(component.defaultOwner).toBe("org1"); + }); + }); + + describe("showOwnership", () => { + it("should return true if ownership change is allowed or in edit mode with at least one organization", () => { + jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true); + expect(component.showOwnership).toBe(true); + + jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false); + component.config.mode = "edit"; + component.config.organizations = [{ id: "org1" } as Organization]; + expect(component.showOwnership).toBe(true); + }); + + it("should hide the ownership control if showOwnership is false", async () => { + jest.spyOn(component, "showOwnership", "get").mockReturnValue(false); + fixture.detectChanges(); + await fixture.whenStable(); + const ownershipControl = fixture.nativeElement.querySelector( + "bit-select[formcontrolname='organizationId']", + ); + expect(ownershipControl).toBeNull(); + }); + + it("should show the ownership control if showOwnership is true", async () => { + jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true); + fixture.detectChanges(); + await fixture.whenStable(); + const ownershipControl = fixture.nativeElement.querySelector( + "bit-select[formcontrolname='organizationId']", + ); + expect(ownershipControl).not.toBeNull(); + }); + }); + + describe("cloneMode", () => { + it("should append '- Clone' to the title if in clone mode", async () => { + component.config.mode = "clone"; + component.config.allowPersonalOwnership = true; + component.originalCipherView = { + name: "cipher1", + organizationId: null, + folderId: null, + collectionIds: null, + favorite: false, + } as CipherView; + + i18nService.t.calledWith("clone").mockReturnValue("Clone"); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.name.value).toBe("cipher1 - Clone"); + }); + + it("should select the first organization if personal ownership is not allowed", async () => { + component.config.mode = "clone"; + component.config.allowPersonalOwnership = false; + component.config.organizations = [ + { id: "org1" } as Organization, + { id: "org2" } as Organization, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: null, + folderId: null, + collectionIds: [], + favorite: false, + } as CipherView; + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.value).toBe("org1"); + }); + }); + + describe("collectionOptions", () => { + it("should reset and disable/hide collections control when no organization is selected", async () => { + component.config.allowPersonalOwnership = true; + component.itemDetailsForm.controls.organizationId.setValue(null); + + fixture.detectChanges(); + await fixture.whenStable(); + + const collectionSelect = fixture.nativeElement.querySelector( + "bit-multi-select[formcontrolname='collectionIds']", + ); + + expect(component.itemDetailsForm.controls.collectionIds.value).toEqual(null); + expect(component.itemDetailsForm.controls.collectionIds.disabled).toBe(true); + expect(collectionSelect).toBeNull(); + }); + + it("should enable/show collection control when an organization is selected", async () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + fixture.detectChanges(); + await fixture.whenStable(); + + const collectionSelect = fixture.nativeElement.querySelector( + "bit-multi-select[formcontrolname='collectionIds']", + ); + + expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true); + expect(collectionSelect).not.toBeNull(); + }); + + it("should set collectionIds to originalCipher collections on first load", async () => { + component.config.mode = "clone"; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1", "col2"], + favorite: true, + } as CipherView; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith( + expect.objectContaining({ + collectionIds: ["col1", "col2"], + }), + ); + }); + + it("should automatically select the first collection if only one is available", async () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.itemDetailsForm.controls.collectionIds.value).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "col1" })]), + ); + }); + + it("should show readonly hint if readonly collections are present", async () => { + component.config.mode = "edit"; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as CipherView; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: true, + } as CollectionView, + ]; + + await component.ngOnInit(); + fixture.detectChanges(); + + const collectionHint = fixture.nativeElement.querySelector( + "bit-hint[data-testid='view-only-hint']", + ); + + expect(collectionHint).not.toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts new file mode 100644 index 0000000000..bb0300cb8f --- /dev/null +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -0,0 +1,270 @@ +import { CommonModule, NgClass } 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, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + SectionComponent, + 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", + standalone: true, + imports: [ + CardComponent, + SectionComponent, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + SelectModule, + SectionHeaderComponent, + IconButtonModule, + NgClass, + 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: string[] = []; + + protected showCollectionsControl: boolean; + + @Input({ required: true }) + config: CipherFormConfig; + + @Input() + originalCipherView: CipherView; + /** + * 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 organizations(): Organization[] { + return this.config.organizations; + } + + get allowPersonalOwnership() { + return this.config.allowPersonalOwnership; + } + + get collections(): CollectionView[] { + return this.config.collections; + } + + get initialValues(): OptionalInitialValues | undefined { + return this.config.initialValues; + } + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private destroyRef: DestroyRef, + ) { + 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({ + name: value.name, + organizationId: value.organizationId, + folderId: value.folderId, + collectionIds: value.collectionIds?.map((c) => c.id) || [], + favorite: value.favorite, + }); + }); + } + + 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. + if (this.config.mode === "edit") { + 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() { + return ( + this.allowOwnershipChange || (this.organizations.length > 0 && this.config.mode === "edit") + ); + } + + get defaultOwner() { + return this.allowPersonalOwnership ? null : this.organizations[0].id; + } + + async ngOnInit() { + if (!this.allowPersonalOwnership && this.organizations.length === 0) { + throw new Error("No organizations available for ownership."); + } + + if (this.originalCipherView) { + await this.initFromExistingCipher(); + } else { + this.itemDetailsForm.setValue({ + name: "", + organizationId: this.initialValues?.organizationId || this.defaultOwner, + folderId: this.initialValues?.folderId || null, + collectionIds: [], + favorite: false, + }); + await this.updateCollectionOptions(this.initialValues?.collectionIds || []); + } + + if (!this.allowOwnershipChange) { + this.itemDetailsForm.controls.organizationId.disable(); + } + + this.itemDetailsForm.controls.organizationId.valueChanges + .pipe( + takeUntilDestroyed(this.destroyRef), + concatMap(async () => { + await this.updateCollectionOptions(); + }), + ) + .subscribe(); + } + + private async initFromExistingCipher() { + this.itemDetailsForm.setValue({ + name: this.originalCipherView.name, + organizationId: this.originalCipherView.organizationId, + folderId: this.originalCipherView.folderId, + collectionIds: [], + favorite: this.originalCipherView.favorite, + }); + + // Configure form for clone mode. + if (this.config.mode === "clone") { + this.itemDetailsForm.controls.name.setValue( + this.originalCipherView.name + " - " + this.i18nService.t("clone"), + ); + + if (!this.allowPersonalOwnership && this.originalCipherView.organizationId == null) { + this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner); + } + } + + await this.updateCollectionOptions(this.originalCipherView.collectionIds as CollectionId[]); + + if (this.partialEdit) { + this.itemDetailsForm.disable(); + this.itemDetailsForm.controls.favorite.enable(); + this.itemDetailsForm.controls.folderId.enable(); + } else if (this.config.mode === "edit") { + // + this.readOnlyCollections = this.collections + .filter( + (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId), + ) + .map((c) => c.name); + } + } + + /** + * 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; + } + + this.collectionOptions = this.collections + .filter((c) => { + // If partial edit mode, show all org collections because the control is disabled. + return c.organizationId === orgId && (this.partialEdit || !c.readOnly); + }) + .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.length > 0) { + collectionsControl.setValue( + this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)), + ); + } + } +} diff --git a/libs/vault/src/cipher-form/index.ts b/libs/vault/src/cipher-form/index.ts new file mode 100644 index 0000000000..4cc762ffb6 --- /dev/null +++ b/libs/vault/src/cipher-form/index.ts @@ -0,0 +1,8 @@ +export { CipherFormModule } from "./cipher-form.module"; +export { + CipherFormConfigService, + CipherFormConfig, + CipherFormMode, + OptionalInitialValues, +} from "./abstractions/cipher-form-config.service"; +export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service"; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts new file mode 100644 index 0000000000..166e1bd58d --- /dev/null +++ b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts @@ -0,0 +1,79 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom, map } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; + +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, +} from "../abstractions/cipher-form-config.service"; + +/** + * Default implementation of the `CipherFormConfigService`. This service should suffice for most use cases, however + * the admin console may need to provide a custom implementation to support admin/custom users who have access to + * collections that are not part of their normal sync data. + */ +@Injectable() +export class DefaultCipherFormConfigService implements CipherFormConfigService { + private policyService: PolicyService = inject(PolicyService); + private organizationService: OrganizationService = inject(OrganizationService); + private cipherService: CipherService = inject(CipherService); + private folderService: FolderService = inject(FolderService); + private collectionService: CollectionService = inject(CollectionService); + + async buildConfig( + mode: CipherFormMode, + cipherId?: CipherId, + cipherType?: CipherType, + ): Promise { + const [organizations, collections, allowPersonalOwnership, folders, cipher] = + await firstValueFrom( + combineLatest([ + this.organizations$, + this.collectionService.decryptedCollections$, + this.allowPersonalOwnership$, + this.folderService.folderViews$, + this.getCipher(cipherId), + ]), + ); + + return { + mode, + cipherType, + admin: false, + allowPersonalOwnership, + originalCipher: cipher, + collections, + organizations, + folders, + }; + } + + private organizations$ = this.organizationService.organizations$.pipe( + map((orgs) => + orgs.filter( + (o) => o.isMember && o.enabled && o.status === OrganizationUserStatusType.Confirmed, + ), + ), + ); + + private allowPersonalOwnership$ = this.policyService + .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + .pipe(map((p) => !p)); + + private getCipher(id?: CipherId): Promise { + if (id == null) { + return Promise.resolve(null); + } + return this.cipherService.get(id); + } +} diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts new file mode 100644 index 0000000000..e8bb1099d5 --- /dev/null +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -0,0 +1,74 @@ +import { inject, Injectable } from "@angular/core"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; +import { CipherFormService } from "../abstractions/cipher-form.service"; + +function isSetEqual(a: Set, b: Set) { + return a.size === b.size && [...a].every((value) => b.has(value)); +} + +@Injectable() +export class DefaultCipherFormService implements CipherFormService { + private cipherService: CipherService = inject(CipherService); + + async decryptCipher(cipher: Cipher): Promise { + return await cipher.decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher)); + } + + async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise { + // Passing the original cipher is important here as it is responsible for appending to password history + const encryptedCipher = await this.cipherService.encrypt( + cipher, + null, + null, + config.originalCipher ?? null, + ); + + let savedCipher: Cipher; + + // Creating a new cipher + if (cipher.id == null) { + savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin); + return await savedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(savedCipher), + ); + } + + if (config.originalCipher == null) { + throw new Error("Original cipher is required for updating an existing cipher"); + } + + // Updating an existing cipher + + const originalCollectionIds = new Set(config.originalCipher.collectionIds ?? []); + const newCollectionIds = new Set(cipher.collectionIds ?? []); + + // If the collectionIds are the same, update the cipher normally + if (isSetEqual(originalCollectionIds, newCollectionIds)) { + savedCipher = await this.cipherService.updateWithServer(encryptedCipher, config.admin); + } else { + // Updating a cipher with collection changes is not supported with a single request currently + // First update the cipher with the original collectionIds + encryptedCipher.collectionIds = config.originalCipher.collectionIds; + await this.cipherService.updateWithServer(encryptedCipher, config.admin); + + // Then save the new collection changes separately + encryptedCipher.collectionIds = cipher.collectionIds; + savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + } + + // Its possible the cipher was made no longer available due to collection assignment changes + // e.g. The cipher was moved to a collection that the user no longer has access to + if (savedCipher == null) { + return null; + } + + return await savedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(savedCipher), + ); + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 00fa042080..82b20bbe53 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -1,3 +1,5 @@ export { PasswordRepromptService } from "./services/password-reprompt.service"; export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; + +export * from "./cipher-form";