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:
parent
9294a4c47e
commit
17d37ecaeb
@ -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",
|
||||||
|
@ -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."
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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$,
|
||||||
|
@ -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'."
|
||||||
|
@ -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;
|
||||||
|
@ -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 { 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";
|
||||||
|
Loading…
Reference in New Issue
Block a user