1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-27 14:53:44 +00:00

wip - working drawer, footer, and title

This commit is contained in:
Isaac Ivins
2026-01-20 17:44:10 -05:00
parent af395b0da3
commit 403cbbcd46
9 changed files with 842 additions and 256 deletions

View File

@@ -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."

View File

@@ -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;
}
}

View File

@@ -0,0 +1,45 @@
<bit-dialog dialogSize="large" background="alt">
<span bitDialogTitle>
{{ title }}
</span>
<div bitDialogContent>
@if (showCipherView) {
<app-cipher-view [cipher]="cipher" [collections]="collections"> </app-cipher-view>
}
@if (loadForm) {
<vault-cipher-form
formId="cipherForm"
[config]="formConfig"
[submitBtn]="itemFooter?.submitBtn"
(formReady)="onFormReady()"
(cipherSaved)="onCipherSaved($event)"
>
<bit-item slot="attachment-button">
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
<ng-container bitDialogFooter>
<app-vault-item-footer
[cipher]="cipher"
[action]="action"
(onEdit)="onEdit()"
(onClone)="onClone($event)"
(onDelete)="onDelete()"
(onRestore)="onRestore()"
(onCancel)="onCancel()"
(onArchiveToggle)="onArchiveToggle()"
>
</app-vault-item-footer>
</ng-container>
</bit-dialog>

View File

@@ -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<void>();
/**
* 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<VaultItemDrawerResult>,
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<VaultItemDrawerResult, VaultItemDrawerParams>(
VaultItemDrawerComponent,
{
data: params,
},
);
}
}

View File

@@ -1,70 +1,11 @@
<div id="vault" class="vault vault-v2" attr.aria-hidden="{{ showingModal }}">
<div id="vault" class="vault vault-v3" attr.aria-hidden="{{ showingModal }}">
<app-vault-items-v2
id="items"
class="items"
[activeCipherId]="cipherId"
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
</div>
<ng-template #folderAddEdit></ng-template>

View File

@@ -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;

View File

@@ -1,75 +1,88 @@
<div class="footer">
<ng-container *ngIf="!cipher.decryptionFailure">
<button
#submitBtn
form="cipherForm"
type="submit"
[hidden]="action === 'view'"
bitButton
class="primary"
appA11yTitle="{{ submitButtonText() }}"
>
{{ submitButtonText() }}
</button>
<button
type="button"
class="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
*ngIf="!cipher.isDeleted && action === 'view'"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
*ngIf="action === 'edit' || action === 'clone' || action === 'add'"
type="button"
(click)="cancel()"
>
<button
#submitBtn
form="cipherForm"
type="submit"
[hidden]="action === 'view'"
bitButton
buttonType="primary"
appA11yTitle="{{ submitButtonText() }}"
>
{{ submitButtonText() }}
</button>
@if (action === "edit" || action === "clone" || action === "add") {
<button type="button" bitButton buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
*ngIf="cipher.isDeleted && cipher.permissions.restore"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="cipher.id && !cipher?.organizationId && !cipher.isDeleted && action === 'view'"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
</ng-container>
<div class="right" *ngIf="hasFooterAction">
<button
type="button"
*ngIf="showArchiveButton"
(click)="archive()"
appA11yTitle="{{ 'archiveVerb' | i18n }}"
>
<i class="bwi bwi-archive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
<button
type="button"
*ngIf="showUnarchiveButton"
(click)="unarchive()"
appA11yTitle="{{ 'unArchive' | i18n }}"
>
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
<button
type="button"
(click)="delete()"
class="danger"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
}
@if (cipher && !cipher.decryptionFailure) {
@if (!cipher.isDeleted && action === "view") {
<button
type="button"
bitButton
buttonType="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
@if (cipher.isDeleted && cipher.permissions.restore) {
<button
type="button"
bitButton
buttonType="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
@if (cipher.id && !cipher?.organizationId && !cipher.isDeleted && action === "view") {
<button
type="button"
bitButton
buttonType="primary"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
}
@if (cipher && hasFooterAction) {
<div class="right">
@if (showArchiveButton) {
<button
type="button"
bitButton
buttonType="secondary"
(click)="archive()"
appA11yTitle="{{ 'archiveVerb' | i18n }}"
>
<i class="bwi bwi-archive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
}
@if (showUnarchiveButton) {
<button
type="button"
bitButton
buttonType="secondary"
(click)="unarchive()"
appA11yTitle="{{ 'unArchive' | i18n }}"
>
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
}
<button
type="button"
bitButton
buttonType="danger"
(click)="delete()"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
}
</div>

View File

@@ -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<string>(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(

View File

@@ -176,6 +176,9 @@ export function getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (flag === FeatureFlag.DesktopUiMigrationMilestone1) {
return true;
}
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}