1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-27 13:43:41 +00:00

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

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

* [PM-7162] Introduce CipherFormConfigService and related types

* [PM-7162] Introduce CipherFormService

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

* [PM-7162] Introduce the CipherForm component

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

* [PM-7162] Export CipherForm from Vault Lib

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

* [PM-7162] Introduce CipherForm storybook

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

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

* [PM-7162] Fix typo

* [PM-7162] Cipher form service cleanup

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

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

* [PM-7162] Fix storybook after config changes

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

View File

@@ -3492,9 +3492,31 @@
"itemsWithNoFolder": {
"message": "Items with no folder"
},
"itemDetails": {
"message": "Item details"
},
"itemName": {
"message": "Item name"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"organizationIsDeactivated": {
"message": "Organization is deactivated"
},
"owner": {
"message": "Owner"
},
"selfOwnershipLabel": {
"message": "You",
"description": "Used as a label to indicate that the user is the owner of an item."
},
"contactYourOrgAdmin": {
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance."
},

View File

@@ -323,12 +323,11 @@ const routes: Routes = [
canActivate: [AuthGuard],
data: { state: "appearance" },
},
{
...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "clone-cipher",
component: AddEditComponent,
canActivate: [AuthGuard],
data: { state: "clone-cipher" },
},
}),
{
path: "send-type",
component: SendTypeComponent,

View File

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

View File

@@ -1,24 +1,86 @@
import { CommonModule } from "@angular/common";
import { CommonModule, Location } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { ActivatedRoute, Params } from "@angular/router";
import { map, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { SearchModule, ButtonModule } from "@bitwarden/components";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components";
import {
CipherFormConfig,
CipherFormConfigService,
CipherFormMode,
CipherFormModule,
DefaultCipherFormConfigService,
} from "@bitwarden/vault";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
/**
* Helper class to parse query parameters for the AddEdit route.
*/
class QueryParams {
constructor(params: Params) {
this.cipherId = params.cipherId;
this.type = parseInt(params.type, null);
this.clone = params.clone === "true";
this.folderId = params.folderId;
this.organizationId = params.organizationId;
this.collectionId = params.collectionId;
this.uri = params.uri;
}
/**
* The ID of the cipher to edit or clone.
*/
cipherId?: CipherId;
/**
* The type of cipher to create.
*/
type: CipherType;
/**
* Whether to clone the cipher.
*/
clone?: boolean;
/**
* Optional folderId to pre-select.
*/
folderId?: string;
/**
* Optional organizationId to pre-select.
*/
organizationId?: OrganizationId;
/**
* Optional collectionId to pre-select.
*/
collectionId?: CollectionId;
/**
* Optional URI to pre-fill for login ciphers.
*/
uri?: string;
}
export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
@Component({
selector: "app-add-edit-v2",
templateUrl: "add-edit-v2.component.html",
standalone: true,
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],
imports: [
CommonModule,
SearchModule,
@@ -29,33 +91,86 @@ import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-a
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
CipherFormModule,
AsyncActionsModule,
],
})
export class AddEditV2Component {
headerText: string;
cipherId: CipherId;
isEdit: boolean = false;
config: CipherFormConfig;
get loading() {
return this.config == null;
}
get originalCipherId(): CipherId | null {
return this.config?.originalCipher.id as CipherId;
}
constructor(
private route: ActivatedRoute,
private location: Location,
private i18nService: I18nService,
private addEditFormConfigService: CipherFormConfigService,
) {
this.subscribeToParams();
}
subscribeToParams(): void {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
const isNew = params.isNew?.toLowerCase() === "true";
const cipherType = parseInt(params.type);
this.isEdit = !isNew;
this.cipherId = params.cipherId;
this.headerText = this.setHeader(isNew, cipherType);
});
onCipherSaved(savedCipher: CipherView) {
this.location.back();
}
setHeader(isNew: boolean, type: CipherType) {
const partOne = isNew ? "newItemHeader" : "editItemHeader";
subscribeToParams(): void {
this.route.queryParams
.pipe(
takeUntilDestroyed(),
map((params) => new QueryParams(params)),
switchMap(async (params) => {
let mode: CipherFormMode;
if (params.cipherId == null) {
mode = "add";
} else {
mode = params.clone ? "clone" : "edit";
}
const config = await this.addEditFormConfigService.buildConfig(
mode,
params.cipherId,
params.type,
);
if (config.mode === "edit" && !config.originalCipher.edit) {
config.mode = "partial-edit";
}
this.setInitialValuesFromParams(params, config);
return config;
}),
)
.subscribe((config) => {
this.config = config;
this.headerText = this.setHeader(config.mode, config.cipherType);
});
}
setInitialValuesFromParams(params: QueryParams, config: CipherFormConfig) {
config.initialValues = {};
if (params.folderId) {
config.initialValues.folderId = params.folderId;
}
if (params.organizationId) {
config.initialValues.organizationId = params.organizationId;
}
if (params.collectionId) {
config.initialValues.collectionIds = [params.collectionId];
}
if (params.uri) {
config.initialValues.loginUri = params.uri;
}
}
setHeader(mode: CipherFormMode, type: CipherType) {
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case CipherType.Login:

View File

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

View File

@@ -1,10 +1,19 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component, Input } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components";
import { ButtonModule, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
export interface NewItemInitialValues {
folderId?: string;
organizationId?: OrganizationId;
collectionId?: CollectionId;
}
@Component({
selector: "app-new-item-dropdown",
@@ -12,17 +21,27 @@ import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components";
standalone: true,
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
})
export class NewItemDropdownV2Component implements OnInit, OnDestroy {
export class NewItemDropdownV2Component {
cipherType = CipherType;
/**
* Optional initial values to pass to the add cipher form
*/
@Input()
initialValues: NewItemInitialValues;
constructor(private router: Router) {}
ngOnInit(): void {}
private buildQueryParams(type: CipherType): AddEditQueryParams {
return {
type: type.toString(),
collectionId: this.initialValues?.collectionId,
organizationId: this.initialValues?.organizationId,
folderId: this.initialValues?.folderId,
};
}
ngOnDestroy(): void {}
// TODO PM-6826: add selectedVault query param
newItemNavigate(type: CipherType) {
void this.router.navigate(["/add-cipher"], { queryParams: { type: type, isNew: true } });
void this.router.navigate(["/add-cipher"], { queryParams: this.buildQueryParams(type) });
}
}

View File

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

View File

@@ -2,9 +2,10 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router";
import { combineLatest } from "rxjs";
import { combineLatest, map, Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
@@ -13,8 +14,12 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
import { NewItemDropdownV2Component } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
import {
NewItemDropdownV2Component,
NewItemInitialValues,
} from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@@ -50,6 +55,17 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected newItemItemValues$: Observable<NewItemInitialValues> =
this.vaultPopupListFiltersService.filters$.pipe(
map((filter) => ({
organizationId: (filter.organization?.id ||
filter.collection?.organizationId) as OrganizationId,
collectionId: filter.collection?.id as CollectionId,
folderId: filter.folder?.id,
})),
shareReplay({ refCount: true, bufferSize: 1 }),
);
/** Visual state of the vault */
protected vaultState: VaultState | null = null;
@@ -59,7 +75,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected VaultStateEnum = VaultState;
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
this.vaultPopupItemsService.noFilteredResults$,

View File

@@ -173,6 +173,10 @@
"message": "No folder",
"description": "This is the folder for uncategorized items"
},
"selfOwnershipLabel": {
"message": "You",
"description": "Used as a label to indicate that the user is the owner of an item."
},
"addFolder": {
"message": "Add folder"
},
@@ -401,6 +405,21 @@
"item": {
"message": "Item"
},
"itemDetails": {
"message": "Item details"
},
"itemName": {
"message": "Item name"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"ex": {
"message": "ex.",
"description": "Short abbreviation for 'example'."
@@ -4159,7 +4178,7 @@
},
"ssoHandOff": {
"message": "You may now close this tab and continue in the extension."
},
},
"youSuccessfullyLoggedIn": {
"message": "You successfully logged in"
},
@@ -8494,7 +8513,7 @@
},
"billingHistoryDescription": {
"message": "Download a CSV to obtain client details for each billing date. Prorated charges are not included in the CSV and may vary from the linked invoice. For the most accurate billing details, refer to your monthly invoices.",
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
},
"monthlySubscriptionUserSeatsMessage": {
"message": "Adjustments to your subscription will result in prorated charges to your billing totals on your next billing period. "