mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-24 12:06:15 +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:
parent
9294a4c47e
commit
17d37ecaeb
@ -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",
|
||||
|
@ -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."
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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$,
|
||||
|
@ -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'."
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
}
|
@ -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>;
|
||||
}
|
28
libs/vault/src/cipher-form/cipher-form-container.ts
Normal file
28
libs/vault/src/cipher-form/cipher-form-container.ts
Normal 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;
|
||||
}
|
17
libs/vault/src/cipher-form/cipher-form.mdx
Normal file
17
libs/vault/src/cipher-form/cipher-form.mdx
Normal 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"]} />
|
17
libs/vault/src/cipher-form/cipher-form.module.ts
Normal file
17
libs/vault/src/cipher-form/cipher-form.module.ts
Normal 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 {}
|
188
libs/vault/src/cipher-form/cipher-form.stories.ts
Normal file
188
libs/vault/src/cipher-form/cipher-form.stories.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
@ -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>
|
212
libs/vault/src/cipher-form/components/cipher-form.component.ts
Normal file
212
libs/vault/src/cipher-form/components/cipher-form.component.ts
Normal 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);
|
||||
};
|
||||
}
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
8
libs/vault/src/cipher-form/index.ts
Normal file
8
libs/vault/src/cipher-form/index.ts
Normal 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";
|
@ -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);
|
||||
}
|
||||
}
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user