1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[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: [ stories: [
"../libs/auth/src/**/*.mdx", "../libs/auth/src/**/*.mdx",
"../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", "../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/**/*.mdx",
"../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)",
"../apps/web/src/**/*.mdx", "../apps/web/src/**/*.mdx",

View File

@ -3492,9 +3492,31 @@
"itemsWithNoFolder": { "itemsWithNoFolder": {
"message": "Items with no folder" "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": { "organizationIsDeactivated": {
"message": "Organization is deactivated" "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": { "contactYourOrgAdmin": {
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." "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], canActivate: [AuthGuard],
data: { state: "appearance" }, data: { state: "appearance" },
}, },
{ ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "clone-cipher", path: "clone-cipher",
component: AddEditComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { state: "clone-cipher" }, data: { state: "clone-cipher" },
}, }),
{ {
path: "send-type", path: "send-type",
component: SendTypeComponent, component: SendTypeComponent,

View File

@ -1,10 +1,21 @@
<popup-page> <popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header> <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"> <popup-footer slot="footer">
<button bitButton type="button" buttonType="primary"> <button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }} {{ "save" | i18n }}
</button> </button>
</popup-footer> </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 { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms"; 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 { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { 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 { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.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({ @Component({
selector: "app-add-edit-v2", selector: "app-add-edit-v2",
templateUrl: "add-edit-v2.component.html", templateUrl: "add-edit-v2.component.html",
standalone: true, standalone: true,
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],
imports: [ imports: [
CommonModule, CommonModule,
SearchModule, SearchModule,
@ -29,33 +91,86 @@ import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-a
PopupPageComponent, PopupPageComponent,
PopupHeaderComponent, PopupHeaderComponent,
PopupFooterComponent, PopupFooterComponent,
CipherFormModule,
AsyncActionsModule,
], ],
}) })
export class AddEditV2Component { export class AddEditV2Component {
headerText: string; headerText: string;
cipherId: CipherId; config: CipherFormConfig;
isEdit: boolean = false;
get loading() {
return this.config == null;
}
get originalCipherId(): CipherId | null {
return this.config?.originalCipher.id as CipherId;
}
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private location: Location,
private i18nService: I18nService, private i18nService: I18nService,
private addEditFormConfigService: CipherFormConfigService,
) { ) {
this.subscribeToParams(); this.subscribeToParams();
} }
subscribeToParams(): void { onCipherSaved(savedCipher: CipherView) {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => { this.location.back();
const isNew = params.isNew?.toLowerCase() === "true"; }
const cipherType = parseInt(params.type);
this.isEdit = !isNew; subscribeToParams(): void {
this.cipherId = params.cipherId; this.route.queryParams
this.headerText = this.setHeader(isNew, cipherType); .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);
}); });
} }
setHeader(isNew: boolean, type: CipherType) { setInitialValuesFromParams(params: QueryParams, config: CipherFormConfig) {
const partOne = isNew ? "newItemHeader" : "editItemHeader"; 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) { switch (type) {
case CipherType.Login: case CipherType.Login:

View File

@ -19,6 +19,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
@Component({ @Component({
standalone: true, standalone: true,
@ -145,9 +146,10 @@ export class ItemMoreOptionsComponent {
await this.router.navigate(["/clone-cipher"], { await this.router.navigate(["/clone-cipher"], {
queryParams: { queryParams: {
cloneMode: true, clone: true.toString(),
cipherId: this.cipher.id, cipherId: this.cipher.id,
}, type: this.cipher.type.toString(),
} as AddEditQueryParams,
}); });
} }
} }

View File

@ -1,10 +1,19 @@
import { CommonModule } from "@angular/common"; 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 { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums"; 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({ @Component({
selector: "app-new-item-dropdown", selector: "app-new-item-dropdown",
@ -12,17 +21,27 @@ import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components";
standalone: true, standalone: true,
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
}) })
export class NewItemDropdownV2Component implements OnInit, OnDestroy { export class NewItemDropdownV2Component {
cipherType = CipherType; cipherType = CipherType;
/**
* Optional initial values to pass to the add cipher form
*/
@Input()
initialValues: NewItemInitialValues;
constructor(private router: Router) {} 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) { 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-page>
<popup-header slot="header" [pageTitle]="'vault' | i18n"> <popup-header slot="header" [pageTitle]="'vault' | i18n">
<ng-container slot="end"> <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-pop-out></app-pop-out>
<app-current-account></app-current-account> <app-current-account></app-current-account>
@ -15,7 +15,10 @@
<bit-no-items [icon]="vaultIcon"> <bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container> <ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">{{ "autofillSuggestionsTip" | 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> </bit-no-items>
</div> </div>

View File

@ -2,9 +2,10 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router"; import { RouterLink } from "@angular/router";
import { combineLatest } from "rxjs"; import { combineLatest, map, Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; 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 { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2"; 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 { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.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 favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; 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 */ /** Visual state of the vault */
protected vaultState: VaultState | null = null; protected vaultState: VaultState | null = null;
@ -59,7 +75,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected VaultStateEnum = VaultState; protected VaultStateEnum = VaultState;
constructor(private vaultPopupItemsService: VaultPopupItemsService) { constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
) {
combineLatest([ combineLatest([
this.vaultPopupItemsService.emptyVault$, this.vaultPopupItemsService.emptyVault$,
this.vaultPopupItemsService.noFilteredResults$, this.vaultPopupItemsService.noFilteredResults$,

View File

@ -173,6 +173,10 @@
"message": "No folder", "message": "No folder",
"description": "This is the folder for uncategorized items" "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": { "addFolder": {
"message": "Add folder" "message": "Add folder"
}, },
@ -401,6 +405,21 @@
"item": { "item": {
"message": "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": { "ex": {
"message": "ex.", "message": "ex.",
"description": "Short abbreviation for 'example'." "description": "Short abbreviation for 'example'."

View File

@ -56,7 +56,11 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
@HostBinding("class") protected classes = ["tw-block", "tw-w-full"]; @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() @Input()
get disabled() { get disabled() {
return this._disabled ?? this.ngControl?.disabled ?? false; 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 { PasswordRepromptService } from "./services/password-reprompt.service";
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
export * from "./cipher-form";