1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-10128] [BEEEP] Add Right-Click Menu Options to Vault (#10954)

* Added right click functionality on cipher row

* Updated menu directive to position menu option on mouse event location

* Updated menu directive to reopen menu option on new mouse event location and close previously opened menu-option

* removed preventdefault call

* Added new events for favorite and edit cipher

* Added new menu options favorite, edit cipher

Added new copy options for the other cipher types

Simplified the copy by using the copy cipher field directive

* Listen to new events

* Refactored parameter to be MouseEvent

* Added locales

* Remove the backdrop from `MenuTriggerForDirective`

* Handle the Angular overlay's outside pointer events

* Cleaned up cipher row component as copy functions and disable menu functions would not be needed anymore

* Fixed bug with right clicking on a row

* Add right click to collections

* Disable backdrop on right click

* Fixed bug where dvivided didn't show for secure notes

* Added comments to enable to disable context menu

* Removed conditionals

* Removed preferences setting to enable to disable setting

* Removed setting from right click listener

* improve context menu positioning to prevent viewport clipping

* Keep icon consisten when favorite or not

* fixed prettier issues

* removed duplicate translation keys

* Fix favorite status not persisting by toggling in handleFavoriteEvent

* Addressed claude comments

* Added comment to variable

---------

Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
SmithThe4th
2025-10-20 09:41:50 -04:00
committed by GitHub
parent 97906fffd9
commit 7c972906aa
8 changed files with 339 additions and 70 deletions

View File

@@ -93,7 +93,7 @@
</span>
</button>
</bit-menu>
@if (!decryptionFailure && !hideMenu) {
@if (!decryptionFailure) {
<button
[bitMenuTriggerFor]="cipherOptions"
[disabled]="disabled"
@@ -104,16 +104,16 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="isActiveLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
<ng-container *ngIf="isLoginCipher">
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
<button bitMenuItem type="button" appCopyField="password" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton">
<button bitMenuItem type="button" appCopyField="totp" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }}
</button>
@@ -130,6 +130,53 @@
</a>
</ng-container>
<ng-container *ngIf="isCardCipher">
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNumber" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySecurityCode" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="isIdentityCipher">
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyEmail" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPhone" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyAddress" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="isSecureNoteCipher">
<button type="button" bitMenuItem appCopyField="secureNote" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNote" | i18n }}
</button>
</ng-container>
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}

View File

@@ -1,15 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import {
Component,
EventEmitter,
HostListener,
Input,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuTriggerForDirective } from "@bitwarden/components";
import {
convertToPermission,
@@ -26,6 +36,8 @@ import { RowHeightClass } from "./vault-items.component";
export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
protected RowHeightClass = RowHeightClass;
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
@Input() disabled: boolean;
@Input() cipher: C;
@Input() showOwner: boolean;
@@ -73,7 +85,10 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
];
protected organization?: Organization;
constructor(private i18nService: I18nService) {}
constructor(
private i18nService: I18nService,
private vaultSettingsService: VaultSettingsService,
) {}
/**
* Lifecycle hook for component initialization.
@@ -180,7 +195,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.useEvents && this.cipher.organizationId;
}
protected get isActiveLoginCipher() {
protected get isLoginCipher() {
return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
!CipherViewLikeUtils.isDeleted(this.cipher) &&
@@ -230,43 +245,63 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.i18nService.t("noAccess");
}
protected get showCopyUsername(): boolean {
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
return this.isActiveLoginCipher && usernameCopy;
}
protected get showCopyPassword(): boolean {
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy;
}
protected get showCopyTotp(): boolean {
return this.isActiveLoginCipher && this.showTotpCopyButton;
}
protected get showLaunchUri(): boolean {
return this.isActiveLoginCipher && this.canLaunch;
}
protected get isDeletedCanRestore(): boolean {
return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher;
}
protected get hideMenu() {
return !(
this.isDeletedCanRestore ||
this.showCopyUsername ||
this.showCopyPassword ||
this.showCopyTotp ||
this.showLaunchUri ||
this.showAttachments ||
this.showClone ||
this.canEditCipher
protected get hasVisibleLoginOptions() {
return (
this.isLoginCipher &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
(this.cipher.viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")) ||
this.showTotpCopyButton ||
this.canLaunch)
);
}
protected copy(field: "username" | "password" | "totp") {
this.onEvent.emit({ type: "copyField", item: this.cipher, field });
protected get isCardCipher(): boolean {
return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Card && !this.isDeleted;
}
protected get hasVisibleCardOptions(): boolean {
return (
this.isCardCipher &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "cardNumber") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "securityCode"))
);
}
protected get isIdentityCipher() {
return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Identity && !this.isDeleted;
}
protected get hasVisibleIdentityOptions(): boolean {
return (
this.isIdentityCipher &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "address") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "email") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "phone"))
);
}
protected get isSecureNoteCipher() {
return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.SecureNote &&
!(this.isDeleted && this.canRestoreCipher)
);
}
protected get hasVisibleSecureNoteOptions(): boolean {
return (
this.isSecureNoteCipher && CipherViewLikeUtils.hasCopyableValue(this.cipher, "secureNote")
);
}
protected get showMenuDivider() {
return (
this.hasVisibleLoginOptions ||
this.hasVisibleCardOptions ||
this.hasVisibleIdentityOptions ||
this.hasVisibleSecureNoteOptions
);
}
protected clone() {
@@ -308,4 +343,26 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword);
}
protected toggleFavorite() {
this.onEvent.emit({
type: "toggleFavorite",
item: this.cipher,
});
}
protected editCipher() {
this.onEvent.emit({ type: "editCipher", item: this.cipher });
}
@HostListener("contextmenu", ["$event"])
protected onRightClick(event: MouseEvent) {
if (event.shiftKey && event.ctrlKey) {
return;
}
if (!this.disabled && this.menuTrigger) {
this.menuTrigger.toggleMenuOnRightClick(event);
}
}
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Component, EventEmitter, HostListener, Input, Output, ViewChild } from "@angular/core";
import {
CollectionAdminView,
@@ -11,6 +11,7 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuTriggerForDirective } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core";
@@ -33,6 +34,8 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected CollectionPermission = CollectionPermission;
protected DefaultCollectionType = CollectionTypes.DefaultUserCollection;
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
@Input() disabled: boolean;
@Input() collection: CollectionView;
@Input() showOwner: boolean;
@@ -122,4 +125,15 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected deleteCollection() {
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
}
@HostListener("contextmenu", ["$event"])
protected onRightClick(event: MouseEvent) {
if (event.shiftKey && event.ctrlKey) {
return;
}
if (!this.disabled && this.menuTrigger) {
this.menuTrigger.toggleMenuOnRightClick(event);
}
}
}

View File

@@ -22,4 +22,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
| { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] };
| { type: "unarchive"; items: C[] }
| { type: "toggleFavorite"; item: C }
| { type: "editCipher"; item: C };

View File

@@ -4,6 +4,7 @@ import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections";
import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module";
@@ -26,6 +27,7 @@ import { VaultItemsComponent } from "./vault-items.component";
CollectionNameBadgeComponent,
GroupBadgeModule,
PipesModule,
CopyCipherFieldDirective,
ScrollLayoutDirective,
],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],

View File

@@ -679,6 +679,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.bulkUnarchive(event.items);
}
break;
case "toggleFavorite":
await this.handleFavoriteEvent(event.item);
break;
case "editCipher":
await this.editCipher(event.item);
break;
}
} finally {
this.processingEvent = false;
@@ -1452,6 +1458,27 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
}
/**
* Toggles the favorite status of the cipher and updates it on the server.
*/
async handleFavoriteEvent(cipher: C) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
cipherFullView.favorite = !cipherFullView.favorite;
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
cipherFullView.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),
});
this.refresh();
}
protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id, userId)

View File

@@ -11028,6 +11028,15 @@
"domainClaimed": {
"message": "Domain claimed"
},
"itemAddedToFavorites": {
"message": "Item added to favorites"
},
"itemRemovedFromFavorites": {
"message": "Item removed from favorites"
},
"copyNote": {
"message": "Copy note"
},
"organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters."
},