[PM-7162] Cipher Form - Item Details (#9758)

* [PM-7162] Fix weird angular error regarding disabled component bit-select

* [PM-7162] Introduce CipherFormConfigService and related types

* [PM-7162] Introduce CipherFormService

* [PM-7162] Introduce the Item Details section component and the CipherFormContainer interface

* [PM-7162] Introduce the CipherForm component

* [PM-7162] Add strongly typed QueryParams to the add-edit-v2.component

* [PM-7162] Export CipherForm from Vault Lib

* [PM-7162] Use the CipherForm in Browser AddEditV2

* [PM-7162] Introduce CipherForm storybook

* [PM-7162] Remove VaultPopupListFilterService dependency from NewItemDropDownV2 component

* [PM-7162] Add support for content projection of attachment button

* [PM-7162] Fix typo

* [PM-7162] Cipher form service cleanup

* [PM-7162] Move readonly collection notice to bit-hint

* [PM-7162] Refactor CipherFormConfig type to enforce required properties with Typescript

* [PM-7162] Fix storybook after config changes

* [PM-7162] Use new add-edit component for clone route
This commit is contained in:
Shane Melton 2024-07-02 13:22:51 -07:00 committed by GitHub
parent 9294a4c47e
commit 17d37ecaeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1737 additions and 40 deletions

View File

@ -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",

View File

@ -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."
},

View File

@ -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,

View File

@ -1,10 +1,21 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
<app-open-attachments *ngIf="isEdit" [cipherId]="cipherId"></app-open-attachments>
<vault-cipher-form
*ngIf="!loading"
formId="cipherForm"
[config]="config"
(cipherSaved)="onCipherSaved($event)"
[submitBtn]="submitBtn"
>
<app-open-attachments
slot="attachment-button"
[cipherId]="originalCipherId"
></app-open-attachments>
</vault-cipher-form>
<popup-footer slot="footer">
<button bitButton type="button" buttonType="primary">
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
</button>
</popup-footer>

View File

@ -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<Record<keyof QueryParams, string>>;
@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:

View File

@ -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,
});
}
}

View File

@ -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) });
}
}

View File

@ -1,7 +1,7 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'vault' | i18n">
<ng-container slot="end">
<app-new-item-dropdown></app-new-item-dropdown>
<app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown>
<app-pop-out></app-pop-out>
<app-current-account></app-current-account>
@ -15,7 +15,10 @@
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
<app-new-item-dropdown slot="button"></app-new-item-dropdown>
<app-new-item-dropdown
slot="button"
[initialValues]="newItemItemValues$ | async"
></app-new-item-dropdown>
</bit-no-items>
</div>

View File

@ -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<NewItemInitialValues> =
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$,

View File

@ -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. "

View File

@ -56,7 +56,11 @@ export class SelectComponent<T> 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;

View File

@ -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<CipherFormConfig>;
}

View File

@ -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<CipherView>;
/**
* Saves the new or modified cipher with the server.
*/
abstract saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView>;
}

View File

@ -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<K extends keyof CipherForm>(
name: K,
group: Exclude<CipherForm[K], undefined>,
): void;
abstract patchCipher(cipher: Partial<CipherView>): void;
}

View File

@ -0,0 +1,17 @@
import { Controls, Meta, Primary } from "@storybook/addon-docs";
import * as stories from "./cipher-form.stories";
<Meta of={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.
<Primary />
<Controls include={["config", "submitBtn"]} />

View File

@ -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 {}

View File

@ -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<CipherView> {
return Promise.resolve(defaultConfig.originalCipher as any);
}
async saveCipher(cipher: CipherView): Promise<CipherView> {
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) => `<div class="tw-bg-background-alt tw-text-main tw-border">${story}</div>`,
),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
],
args: {
config: defaultConfig,
},
argTypes: {
config: {
description: "The configuration object for the form.",
},
},
} as Meta;
type Story = StoryObj<CipherFormComponent>;
export const Default: Story = {
render: (args) => {
return {
props: {
onSave: actionsData.onSave,
...args,
},
template: /*html*/ `
<vault-cipher-form [config]="config" (cipherSaved)="onSave($event)" formId="test-form" [submitBtn]="submitBtn"></vault-cipher-form>
<button type="submit" form="test-form" bitButton buttonType="primary" #submitBtn>Submit</button>
`,
};
},
};
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,
},
},
};

View File

@ -0,0 +1,14 @@
<form [id]="formId" [formGroup]="cipherForm" [bitSubmit]="submit">
<!-- TODO: Should we show a loading spinner here? Or emit a ready event for the container to handle loading state -->
<ng-container *ngIf="!loading">
<vault-item-details-section
[config]="config"
[originalCipherView]="originalCipherView"
></vault-item-details-section>
<!-- Attachments are only available for existing ciphers -->
<ng-container *ngIf="config.mode == 'edit'">
<ng-content select="[slot=attachment-button]"></ng-content>
</ng-container>
</ng-container>
</form>

View File

@ -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<CipherView>();
/**
* The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method.
* @protected
*/
protected cipherForm = this.formBuilder.group<CipherForm>({});
/**
* 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<K extends keyof CipherForm>(
name: K,
group: Exclude<CipherForm[K], undefined>,
): 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<CipherView>): 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);
};
}

View File

@ -0,0 +1,61 @@
<bit-section [formGroup]="itemDetailsForm">
<bit-section-header>
<h2 bitTypography="h5">{{ "itemDetails" | i18n }}</h2>
<button
slot="end"
type="button"
size="small"
[bitIconButton]="favoriteIcon"
role="checkbox"
[attr.aria-checked]="itemDetailsForm.value.favorite"
[appA11yTitle]="'favorite' | i18n"
(click)="toggleFavorite()"
></button>
</bit-section-header>
<bit-card>
<bit-form-field>
<bit-label>{{ "itemName" | i18n }}</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<div class="tw-flex tw-flex-wrap tw-gap-1">
<bit-form-field class="tw-flex-1" *ngIf="showOwnership">
<bit-label>{{ "owner" | i18n }}</bit-label>
<bit-select formControlName="organizationId">
<bit-option
*ngIf="allowPersonalOwnership"
[value]="null"
[label]="'selfOwnershipLabel' | i18n"
></bit-option>
<bit-option
*ngFor="let org of config.organizations"
[value]="org.id"
[label]="org.name"
></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field class="tw-flex-1">
<bit-label>{{ "folder" | i18n }}</bit-label>
<bit-select formControlName="folderId">
<bit-option
*ngFor="let folder of config.folders"
[value]="folder.id"
[label]="folder.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<ng-container *ngIf="showCollectionsControl">
<bit-form-field class="tw-w-full">
<bit-label>{{ "collections" | i18n }}</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="collectionIds"
[baseItems]="collectionOptions"
></bit-multi-select>
<bit-hint *ngIf="readOnlyCollections.length > 0" data-testid="view-only-hint">
{{ "cannotRemoveViewOnlyCollections" | i18n: readOnlyCollections.join(", ") }}
</bit-hint>
</bit-form-field>
</ng-container>
</bit-card>
</bit-section>

View File

@ -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<ItemDetailsSectionComponent>;
let cipherFormProvider: MockProxy<CipherFormContainer>;
let i18nService: MockProxy<I18nService>;
beforeEach(async () => {
cipherFormProvider = mock<CipherFormContainer>();
i18nService = mock<I18nService>();
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();
});
});
});

View File

@ -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)),
);
}
}
}

View File

@ -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";

View File

@ -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<CipherFormConfig> {
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<Cipher | null> {
if (id == null) {
return Promise.resolve(null);
}
return this.cipherService.get(id);
}
}

View File

@ -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<string>, b: Set<string>) {
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<CipherView> {
return await cipher.decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher));
}
async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView> {
// 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),
);
}
}

View File

@ -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";