diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 56eacc94e50..bc1b03e3559 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -160,6 +160,66 @@ "message": "Edit File Send", "description": "Header for edit file send" }, + "viewItemHeaderLogin": { + "message": "View Login", + "description": "Header for view login item type" + }, + "viewItemHeaderCard": { + "message": "View Card", + "description": "Header for view card item type" + }, + "viewItemHeaderIdentity": { + "message": "View Identity", + "description": "Header for view identity item type" + }, + "viewItemHeaderNote": { + "message": "View Note", + "description": "Header for view note item type" + }, + "viewItemHeaderSshKey": { + "message": "View SSH key", + "description": "Header for view SSH key item type" + }, + "newItemHeaderLogin": { + "message": "New Login", + "description": "Header for new login item type" + }, + "newItemHeaderCard": { + "message": "New Card", + "description": "Header for new card item type" + }, + "newItemHeaderIdentity": { + "message": "New Identity", + "description": "Header for new identity item type" + }, + "newItemHeaderNote": { + "message": "New Note", + "description": "Header for new note item type" + }, + "newItemHeaderSshKey": { + "message": "New SSH key", + "description": "Header for new SSH key item type" + }, + "editItemHeaderLogin": { + "message": "Edit Login", + "description": "Header for edit login item type" + }, + "editItemHeaderCard": { + "message": "Edit Card", + "description": "Header for edit card item type" + }, + "editItemHeaderIdentity": { + "message": "Edit Identity", + "description": "Header for edit identity item type" + }, + "editItemHeaderNote": { + "message": "Edit Note", + "description": "Header for edit note item type" + }, + "editItemHeaderSshKey": { + "message": "Edit SSH key", + "description": "Header for edit SSH key item type" + }, "deleteSendPermanentConfirmation": { "message": "Are you sure you want to permanently delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/apps/desktop/src/scss/vault.scss b/apps/desktop/src/scss/vault.scss index 88216a2b926..cd7e56833f3 100644 --- a/apps/desktop/src/scss/vault.scss +++ b/apps/desktop/src/scss/vault.scss @@ -166,3 +166,14 @@ app-root { .vault-v2 > .details { flex-direction: column-reverse; } + +// Vault V3 - Drawer-specific styles +.vault-v3 { + > .items { + flex: 1; + min-width: 0; + width: auto; + max-width: none; + border-right: none; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.html b/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.html new file mode 100644 index 00000000000..d1ddcb4eadf --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.html @@ -0,0 +1,45 @@ + + + {{ title }} + + +
+ @if (showCipherView) { + + } + + @if (loadForm) { + + + + + + } +
+ + + + + +
diff --git a/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.ts new file mode 100644 index 00000000000..26e2c1c1d9a --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-item-drawer.component.ts @@ -0,0 +1,407 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnDestroy, ViewChild } from "@angular/core"; +import { firstValueFrom, Subject } from "rxjs"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +// import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DIALOG_DATA, + DialogRef, + AsyncActionsModule, + ButtonModule, + DialogService, + DialogModule, + ItemModule, +} from "@bitwarden/components"; +import { + // AttachmentDialogResult, + // AttachmentsV2Component, + CipherFormComponent, + CipherFormConfig, + CipherFormModule, + CipherViewComponent, +} from "@bitwarden/vault"; + +import { ItemFooterComponent } from "../vault/item-footer.component"; + +export interface VaultItemDrawerParams { + /** + * The configuration object for the cipher form. + */ + config: CipherFormConfig; + + /** + * The initial mode for the drawer: 'view' | 'add' | 'edit' | 'clone' + */ + initialMode: "view" | "add" | "edit" | "clone"; +} + +/** A result of the vault item drawer. */ +export const VaultItemDrawerResult = Object.freeze({ + /** The cipher was saved. */ + Saved: "saved", + /** The cipher was deleted. */ + Deleted: "deleted", + /** The cipher was archived/unarchived. */ + Archived: "archived", + /** The cipher was restored. */ + Restored: "restored", +} as const); + +/** A result of the vault item drawer. */ +export type VaultItemDrawerResult = { + result: (typeof VaultItemDrawerResult)[keyof typeof VaultItemDrawerResult]; + cipher?: CipherView; +}; + +type DrawerMode = "view" | "form"; + +/** + * Component for viewing or editing a vault item in a drawer. + * Supports both view and edit modes with in-drawer switching. + */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "vault-item-drawer.component.html", + imports: [ + CommonModule, + JslibModule, + ButtonModule, + CipherFormModule, + CipherViewComponent, + AsyncActionsModule, + DialogModule, + ItemFooterComponent, + ItemModule, + PremiumBadgeComponent, + ], +}) +export class VaultItemDrawerComponent implements OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(ItemFooterComponent) itemFooter: ItemFooterComponent | null = null; + + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent; + + /** + * The title of the drawer. + */ + protected title: string; + + /** + * Current mode of the drawer: 'view' or 'form' + */ + protected mode: DrawerMode; + + /** + * Flag to initialize/attach the form component. + */ + protected loadForm: boolean; + + /** + * Flag to indicate the form is ready to be displayed. + */ + protected formReady = false; + + /** + * The configuration for the cipher form. + */ + protected formConfig: CipherFormConfig; + + /** + * The cipher being viewed or edited. + */ + protected cipher: CipherView | null = null; + + /** + * Collections the cipher is assigned to. + */ + protected collections: CollectionView[] = []; + + /** + * The action to pass to ItemFooterComponent ('view', 'add', 'edit', 'clone') + */ + protected get action(): string { + if (this.mode === "view") { + return "view"; + } + return this.formConfig.mode; + } + + /** + * Whether to show the cipher view component. + */ + protected get showCipherView(): boolean { + return this.cipher != null && this.mode === "view"; + } + + /** + * Whether the form is loading (initialized but not ready). + */ + protected get loadingForm(): boolean { + return this.loadForm && !this.formReady; + } + + /** + * Tracks if the cipher was ever modified while the drawer was open. + * @private + */ + private _cipherModified = false; + + /** + * Subject to emit when the form is ready to be displayed. + * @private + */ + private _formReadySubject = new Subject(); + + /** + * The initial mode from params (used to determine behavior after save). + * @private + */ + private _initialMode = this.params.initialMode; + + constructor( + @Inject(DIALOG_DATA) protected params: VaultItemDrawerParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private dialogService: DialogService, + ) { + this.formConfig = params.config; + this.cipher = params.config.originalCipher + ? new CipherView(params.config.originalCipher) + : null; + + if (this.cipher && this.formConfig.collections) { + this.collections = this.formConfig.collections.filter((c) => + this.cipher.collectionIds?.includes(c.id), + ); + } + + // Set initial mode + this.mode = params.initialMode === "view" ? "view" : "form"; + this.loadForm = this.mode === "form"; + + this.updateTitle(); + } + + ngOnDestroy() { + // If the cipher was modified, be sure we emit the saved result in case the dialog was closed with the X button or ESC key. + if (this._cipherModified) { + this.dialogRef.close({ result: VaultItemDrawerResult.Saved }); + } + } + + /** + * Called by the CipherFormComponent when the cipher is saved successfully. + */ + protected async onCipherSaved(cipherView: CipherView) { + this.cipher = cipherView; + this._cipherModified = true; + + if (this.formConfig.collections) { + this.collections = this.formConfig.collections.filter((c) => + cipherView.collectionIds?.includes(c.id), + ); + } + + // If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits. + if (this._initialMode === "add" || this._initialMode === "clone") { + this.formConfig.mode = "edit"; + this.formConfig.initialValues = null; + } + + // Switch back to view mode after save + await this.changeMode("view"); + } + + /** + * Called by the CipherFormComponent when the form is ready to be displayed. + */ + protected onFormReady() { + this.formReady = true; + this._formReadySubject.next(); + } + + /** + * Called when ItemFooter emits onEdit event. + * Switches from view mode to edit mode. + */ + protected async onEdit() { + await this.changeMode("form"); + } + + /** + * Called when ItemFooter emits onClone event. + */ + protected async onClone(cipher: CipherView) { + // Close drawer and let parent handle cloning by reopening with clone mode + this.dialogRef.close({ result: VaultItemDrawerResult.Saved }); + } + + /** + * Called when ItemFooter emits onDelete event. + */ + protected onDelete() { + this._cipherModified = false; + this.dialogRef.close({ result: VaultItemDrawerResult.Deleted }); + } + + /** + * Called when ItemFooter emits onRestore event. + */ + protected onRestore() { + this._cipherModified = false; + this.dialogRef.close({ result: VaultItemDrawerResult.Restored }); + } + + /** + * Called when ItemFooter emits onCancel event. + * Returns to view mode or closes drawer if no cipher exists. + */ + protected async onCancel() { + // We're in View mode, we don't have a cipher, or we were adding/cloning, close the drawer. + if ( + this.mode === "view" || + this.cipher == null || + this._initialMode === "add" || + this._initialMode === "clone" + ) { + this.dialogRef.close( + this._cipherModified ? { result: VaultItemDrawerResult.Saved } : undefined, + ); + return; + } + + // We're in Form mode, and we have a cipher, switch back to View mode. + await this.changeMode("view"); + } + + /** + * Called when ItemFooter emits onArchiveToggle event. + */ + protected async onArchiveToggle() { + // ItemFooter handles the archive logic, just refresh and indicate change + this._cipherModified = true; + await this.refreshCurrentCipher(); + } + + /** + * Opens the attachments dialog for the current cipher. + */ + protected async openAttachmentsDialog() { + // const dialogRef = AttachmentsV2Component.open(this.dialogService, { + // cipherId: this.formConfig.originalCipher?.id as CipherId, + // organizationId: this.formConfig.originalCipher?.organizationId as OrganizationId, + // }); + // const result = await firstValueFrom(dialogRef.closed); + // if ( + // result.action === AttachmentDialogResult.Removed || + // result.action === AttachmentDialogResult.Uploaded + // ) { + // // Update the cipher form with the new attachments + // this.cipherFormComponent.patchCipher((currentCipher) => { + // currentCipher.attachments = result.cipher?.attachments; + // currentCipher.revisionDate = result.cipher?.revisionDate; + // return currentCipher; + // }); + // this._cipherModified = true; + // } + } + + /** + * Refresh the current cipher after an action like archive. + * @private + */ + private async refreshCurrentCipher() { + // The cipher should be updated by the ItemFooter's archive action + // We just need to mark it as modified + this._cipherModified = true; + } + + /** + * Changes the mode of the drawer. When switching to Form mode, the form is initialized first then displayed once ready. + * @param mode + * @private + */ + private async changeMode(mode: DrawerMode) { + this.formReady = false; + + if (mode === "form") { + this.loadForm = true; + // Wait for the formReadySubject to emit before continuing. + // This helps prevent flashing an empty dialog while the form is initializing. + await firstValueFrom(this._formReadySubject); + } else { + this.loadForm = false; + } + + this.mode = mode; + this.updateTitle(); + } + + /** + * Updates the title based on current mode and cipher type. + * @private + */ + private updateTitle(): void { + const translation: { [key: string]: { [key: number]: string } } = { + view: { + [CipherType.Login]: "viewItemHeaderLogin", + [CipherType.Card]: "viewItemHeaderCard", + [CipherType.Identity]: "viewItemHeaderIdentity", + [CipherType.SecureNote]: "viewItemHeaderNote", + [CipherType.SshKey]: "viewItemHeaderSshKey", + }, + new: { + [CipherType.Login]: "newItemHeaderLogin", + [CipherType.Card]: "newItemHeaderCard", + [CipherType.Identity]: "newItemHeaderIdentity", + [CipherType.SecureNote]: "newItemHeaderNote", + [CipherType.SshKey]: "newItemHeaderSshKey", + }, + edit: { + [CipherType.Login]: "editItemHeaderLogin", + [CipherType.Card]: "editItemHeaderCard", + [CipherType.Identity]: "editItemHeaderIdentity", + [CipherType.SecureNote]: "editItemHeaderNote", + [CipherType.SshKey]: "editItemHeaderSshKey", + }, + }; + + const type = this.cipher?.type ?? this.formConfig.cipherType; + let titleMode: "view" | "edit" | "new" = "view"; + + if (this.mode === "form") { + titleMode = + this.formConfig.mode === "edit" || this.formConfig.mode === "partial-edit" ? "edit" : "new"; + } + + const fullTranslation = translation[titleMode][type]; + this.title = this.i18nService.t(fullTranslation); + } + + /** + * Opens the vault item drawer. + * @param dialogService Instance of the DialogService. + * @param params The parameters for the drawer. + * @returns The drawer result. + */ + static openDrawer(dialogService: DialogService, params: VaultItemDrawerParams) { + return dialogService.openDrawer( + VaultItemDrawerComponent, + { + data: params, + }, + ); + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.html b/apps/desktop/src/vault/app/vault-v3/vault.component.html index a9a25f57994..b5097a61a8e 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -1,70 +1,11 @@ -
+
-
- -
-
-
- - - - - - - -
-
-
-
-
diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index 64f850826a3..9ed6a88d754 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -13,7 +13,7 @@ import { import { filter, map, take } from "rxjs/operators"; import { CollectionService } from "@bitwarden/admin-console/common"; -import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +// import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -65,8 +65,8 @@ import { CipherFormConfigService, CipherFormGenerationService, CipherFormMode, - CipherFormModule, - CipherViewComponent, + // CipherFormModule, + // CipherViewComponent, CollectionAssignmentResult, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, @@ -84,9 +84,10 @@ import { DesktopCredentialGenerationService } from "../../../services/desktop-ci import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; import { invokeMenu, RendererMenuItem } from "../../../utils"; import { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; -import { ItemFooterComponent } from "../vault/item-footer.component"; import { VaultItemsV2Component } from "../vault/vault-items-v2.component"; +import { VaultItemDrawerComponent, VaultItemDrawerResult } from "./vault-item-drawer.component"; + const BroadcasterSubscriptionId = "VaultComponent"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -94,18 +95,7 @@ const BroadcasterSubscriptionId = "VaultComponent"; @Component({ selector: "app-vault-v3", templateUrl: "vault.component.html", - imports: [ - BadgeModule, - CommonModule, - CipherFormModule, - CipherViewComponent, - ItemFooterComponent, - I18nPipe, - ItemModule, - ButtonModule, - PremiumBadgeComponent, - VaultItemsV2Component, - ], + imports: [BadgeModule, CommonModule, I18nPipe, ItemModule, ButtonModule, VaultItemsV2Component], providers: [ { provide: CipherFormConfigService, @@ -286,20 +276,20 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } break; } - case "copyTotp": { - if ( - this.cipher?.login?.hasTotp && - (this.cipher.organizationUseTotp || this.userHasPremiumAccess) - ) { - const value = await firstValueFrom( - this.totpService.getCode$(this.cipher.login.totp), - ).catch((): any => null); - if (value) { - this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); - } - } - break; - } + // case "copyTotp": { + // if ( + // this.cipher?.login?.hasTotp && + // (this.cipher.organizationUseTotp || this.userHasPremiumAccess) + // ) { + // const value = await firstValueFrom( + // this.totpService.getCode$(this.cipher.login.totp), + // ).catch((): any => null); + // if (value) { + // this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); + // } + // } + // break; + // } default: detectChanges = false; break; @@ -416,19 +406,40 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { if (await this.shouldReprompt(cipher, "view")) { return; } - this.cipherId = cipher.id; - this.cipher = cipher; - this.collections = - this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null; - this.action = "view"; - await this.go().catch(() => {}); + // Build config for the drawer + const config = await this.formConfigService + .buildConfig("edit", cipher.id as CipherId, undefined) + .catch((): any => null); + + if (!config) { + return; + } + + // Open drawer in view mode + const drawerRef = VaultItemDrawerComponent.openDrawer(this.dialogService, { + config, + initialMode: "view", + }); + await this.eventCollectionService.collect( EventType.Cipher_ClientViewed, cipher.id, false, cipher.organizationId, ); + + const result = await lastValueFrom(drawerRef.closed); + + // Refresh list if cipher was modified + if (result?.result === VaultItemDrawerResult.Saved) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } else if ( + result?.result === VaultItemDrawerResult.Deleted || + result?.result === VaultItemDrawerResult.Restored + ) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } } formStatusChanged(status: "disabled" | "enabled") { @@ -574,64 +585,64 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { ) { menu.push({ type: "separator" }); } - if (cipher.login.canLaunch) { - menu.push({ - label: this.i18nService.t("launch"), - click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), - }); - } - if (cipher.login.username != null) { - menu.push({ - label: this.i18nService.t("copyUsername"), - click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), - }); - } - if (cipher.login.password != null && cipher.viewPassword) { - menu.push({ - label: this.i18nService.t("copyPassword"), - click: () => { - this.copyValue(cipher, cipher.login.password, "password", "Password"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) - .catch(() => {}); - }, - }); - } - if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { - menu.push({ - label: this.i18nService.t("copyVerificationCodeTotp"), - click: async () => { - const value = await firstValueFrom( - this.totpService.getCode$(cipher.login.totp), - ).catch((): any => null); - if (value) { - this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); - } - }, - }); - } + // if (cipher.login.canLaunch) { + // menu.push({ + // label: this.i18nService.t("launch"), + // click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + // }); + // } + // if (cipher.login.username != null) { + // menu.push({ + // label: this.i18nService.t("copyUsername"), + // click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + // }); + // } + // if (cipher.login.password != null && cipher.viewPassword) { + // menu.push({ + // label: this.i18nService.t("copyPassword"), + // click: () => { + // this.copyValue(cipher, cipher.login.password, "password", "Password"); + // this.eventCollectionService + // .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + // .catch(() => {}); + // }, + // }); + // } + // if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + // menu.push({ + // label: this.i18nService.t("copyVerificationCodeTotp"), + // click: async () => { + // const value = await firstValueFrom( + // this.totpService.getCode$(cipher.login.totp), + // ).catch((): any => null); + // if (value) { + // this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + // } + // }, + // }); + // } break; case CipherType.Card: if (cipher.card.number != null || cipher.card.code != null) { menu.push({ type: "separator" }); } - if (cipher.card.number != null) { - menu.push({ - label: this.i18nService.t("copyNumber"), - click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), - }); - } - if (cipher.card.code != null) { - menu.push({ - label: this.i18nService.t("copySecurityCode"), - click: () => { - this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) - .catch(() => {}); - }, - }); - } + // if (cipher.card.number != null) { + // menu.push({ + // label: this.i18nService.t("copyNumber"), + // click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + // }); + // } + // if (cipher.card.code != null) { + // menu.push({ + // label: this.i18nService.t("copySecurityCode"), + // click: () => { + // this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + // this.eventCollectionService + // .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + // .catch(() => {}); + // }, + // }); + // } break; default: break; @@ -653,25 +664,62 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { if (await this.shouldReprompt(cipher, "edit")) { return; } - this.cipherId = cipher.id; - this.cipher = cipher; - await this.buildFormConfig("edit"); - if (!cipher.edit && this.config) { - this.config.mode = "partial-edit"; + + // Build config for the drawer + const config = await this.formConfigService + .buildConfig("edit", cipher.id as CipherId, undefined) + .catch((): any => null); + + if (!config) { + return; + } + + if (!cipher.edit) { + config.mode = "partial-edit"; + } + + // Open drawer in edit mode + const drawerRef = VaultItemDrawerComponent.openDrawer(this.dialogService, { + config, + initialMode: "edit", + }); + + const result = await lastValueFrom(drawerRef.closed); + + // Refresh list if cipher was modified + if (result?.result === VaultItemDrawerResult.Saved) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } else if (result?.result === VaultItemDrawerResult.Deleted) { + await this.vaultItemsComponent?.refresh().catch(() => {}); } - this.action = "edit"; - await this.go().catch(() => {}); } async cloneCipher(cipher: CipherView) { if (await this.shouldReprompt(cipher, "clone")) { return; } - this.cipherId = cipher.id; - this.cipher = cipher; - await this.buildFormConfig("clone"); - this.action = "clone"; - await this.go().catch(() => {}); + + // Build config for the drawer + const config = await this.formConfigService + .buildConfig("clone", cipher.id as CipherId, undefined) + .catch((): any => null); + + if (!config) { + return; + } + + // Open drawer in clone mode + const drawerRef = VaultItemDrawerComponent.openDrawer(this.dialogService, { + config, + initialMode: "clone", + }); + + const result = await lastValueFrom(drawerRef.closed); + + // Refresh list if cipher was modified + if (result?.result === VaultItemDrawerResult.Saved) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } } async shareCipher(cipher: CipherView) { @@ -713,16 +761,25 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } async addCipher(type: CipherType) { - if (this.action === "add") { + this.addType = type || this.activeFilter.cipherType; + + // Build config for the drawer + const config = await this.formConfigService + .buildConfig("add", undefined, this.addType) + .catch((): any => null); + + if (!config) { return; } - this.addType = type || this.activeFilter.cipherType; - this.cipher = new CipherView(); - this.cipherId = null; - await this.buildFormConfig("add"); - this.action = "add"; - this.prefillCipherFromFilter(); - await this.go().catch(() => {}); + + // Prefill cipher from filter + this.prefillCipherConfig(config); + + // Open drawer in add mode + const drawerRef = VaultItemDrawerComponent.openDrawer(this.dialogService, { + config, + initialMode: "add", + }); if (type === CipherType.SshKey) { this.toastService.showToast({ @@ -731,6 +788,13 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { message: this.i18nService.t("sshKeyGenerated"), }); } + + const result = await lastValueFrom(drawerRef.closed); + + // Refresh list if cipher was created + if (result?.result === VaultItemDrawerResult.Saved) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } } async savedCipher(cipher: CipherView) { @@ -798,6 +862,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }; return filterFn(proxyCipher as any); } + return false; }; } @@ -971,12 +1036,48 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { this.config.initialValues = { ...this.config.initialValues, - folderId: this.folderId, + folderId: this.folderId || undefined, organizationId: this.addOrganizationId as OrganizationId, collectionIds: this.addCollectionIds as CollectionId[], }; } + /** + * Prefill cipher config based on active filter selections + */ + private prefillCipherConfig(config: CipherFormConfig) { + if (config == null) { + return; + } + + let addOrganizationId: string | null = null; + let addCollectionIds: string[] | null = null; + let folderId: string | null | undefined = null; + + if (this.activeFilter.collectionId != null) { + const collections = this.filteredCollections?.filter( + (c) => c.id === this.activeFilter.collectionId, + ); + if (collections?.length > 0) { + addOrganizationId = collections[0].organizationId; + addCollectionIds = [this.activeFilter.collectionId]; + } + } else if (this.activeFilter.organizationId) { + addOrganizationId = this.activeFilter.organizationId; + } + + if (this.activeFilter.folderId && this.activeFilter.selectedFolderNode) { + folderId = this.activeFilter.folderId; + } + + config.initialValues = { + ...config.initialValues, + organizationId: addOrganizationId as OrganizationId, + folderId: folderId || undefined, + collectionIds: addCollectionIds as CollectionId[], + }; + } + private async canNavigateAway(action: string, cipher?: CipherView) { if (this.action === action && (!cipher || this.cipherId === cipher.id)) { return false; diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index a03f3e96b06..39c0615e74d 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -1,75 +1,88 @@ diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index c80e4e59ae4..48dc74c2f3d 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -9,6 +9,7 @@ import { OnChanges, SimpleChanges, input, + inject, } from "@angular/core"; import { combineLatest, firstValueFrom, switchMap } from "rxjs"; @@ -36,7 +37,7 @@ import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitward export class ItemFooterComponent implements OnInit, OnChanges { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ required: true }) cipher: CipherView = new CipherView(); + @Input({ required: true }) cipher: CipherView | null = new CipherView(); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collectionId: string | null = null; @@ -68,6 +69,17 @@ export class ItemFooterComponent implements OnInit, OnChanges { // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null; + protected cipherService = inject(CipherService); + protected dialogService = inject(DialogService); + protected passwordRepromptService = inject(PasswordRepromptService); + protected cipherAuthorizationService = inject(CipherAuthorizationService); + protected accountService = inject(AccountService); + protected toastService = inject(ToastService); + protected i18nService = inject(I18nService); + protected logService = inject(LogService); + protected cipherArchiveService = inject(CipherArchiveService); + protected archiveCipherUtilitiesService = inject(ArchiveCipherUtilitiesService); + readonly submitButtonText = input(this.i18nService.t("save")); activeUserId: UserId | null = null; @@ -76,19 +88,6 @@ export class ItemFooterComponent implements OnInit, OnChanges { protected showArchiveButton = false; protected showUnarchiveButton = false; - constructor( - protected cipherService: CipherService, - protected dialogService: DialogService, - protected passwordRepromptService: PasswordRepromptService, - protected cipherAuthorizationService: CipherAuthorizationService, - protected accountService: AccountService, - protected toastService: ToastService, - protected i18nService: I18nService, - protected logService: LogService, - protected cipherArchiveService: CipherArchiveService, - protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, - ) {} - async ngOnInit() { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); this.passwordReprompted = this.masterPasswordAlreadyPrompted; @@ -130,7 +129,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { return ( this.showArchiveButton || this.showUnarchiveButton || - (this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view")) + (this.cipher?.permissions?.delete && (this.action === "edit" || this.action === "view")) ); } @@ -221,6 +220,12 @@ export class ItemFooterComponent implements OnInit, OnChanges { } private async checkArchiveState() { + if (!this.cipher) { + this.showArchiveButton = false; + this.showUnarchiveButton = false; + return; + } + const cipherCanBeArchived = !this.cipher.isDeleted; const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom( this.accountService.activeAccount$.pipe( diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 00de0d25ccd..5d6f29767b2 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -176,6 +176,9 @@ export function getFeatureFlagValue( serverConfig: ServerConfig | null, flag: Flag, ) { + if (flag === FeatureFlag.DesktopUiMigrationMilestone1) { + return true; + } if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) { return DefaultFeatureFlagValue[flag]; }