mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-13 00:51:45 +01:00
PM-9665: implement view item view (#10416)
* Add initial view cipher dialog. * Add working view cipher modal dialog markup. * Cleanup dialog markup and allow edit from dialog. * Cleanup unused imports. * Begin adding org-vault view-cipher functionality. * Refactor to remove loose-components usage and use DialogService. * Add edit and delete button functionality. * Add delete functionality. * Remove addition to loose components. * Remove unused modal-dialog artifacts. * Ensure dialog closes and URL updates properly on edit or close. * Disable edit/delete buttons instead of hiding them. * Add simple tests for view.component.ts. * Adjust import order. * Remove now unnecessary ng-template. * Decrypt cipher to cipher view. * Add cleanup function and additional delete test. * Remove boolean return from delete promise. * Remove fake timers. * Remove unnecessary TestBed.inject calls. * Add code comments. * Hide new view cipher dialog behind feature flag. * Keep "else if" statement intact. * Simplify getting cipherTypeString. * Add comments to vault.component.ts files. * Change button type to "danger" Update apps/web/src/app/vault/individual-vault/view.component.html Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> * Add a11y title to delete button. * Simplify OrganizationService testing. * Update comment to better reflect function. * Use large dialog to better match designs. * Add aria-haspopup to cipher row button. * Add deleteCipher to messages.json. * Remove extra argument from canEditAllCiphers. * Use 'delete' instead of 'delete cipher' for a11y title. * Remove 'bitFormButton' from non-form buttons. * Rework view cipher view delete functionality. * Add translations for cipher types. * Remove unecesarry test. * Add additional test coverage to ensure dialogs close. * Add back delete functionality in view.component.ts. * Update "secure note" to "note". --------- Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
This commit is contained in:
parent
d62bdd6e6c
commit
3a31eb2f10
@ -19,11 +19,12 @@
|
|||||||
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
|
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[routerLink]="[]"
|
[routerLink]="[]"
|
||||||
[queryParams]="{ itemId: cipher.id }"
|
[queryParams]="{ itemId: cipher.id, action: extensionRefreshEnabled ? 'view' : null }"
|
||||||
queryParamsHandling="merge"
|
queryParamsHandling="merge"
|
||||||
title="{{ 'editItemWithName' | i18n: cipher.name }}"
|
title="{{ 'editItemWithName' | i18n: cipher.name }}"
|
||||||
type="button"
|
type="button"
|
||||||
appStopProp
|
appStopProp
|
||||||
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
{{ cipher.name }}
|
{{ cipher.name }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||||
@ -12,9 +15,14 @@ import { RowHeightClass } from "./vault-items.component";
|
|||||||
selector: "tr[appVaultCipherRow]",
|
selector: "tr[appVaultCipherRow]",
|
||||||
templateUrl: "vault-cipher-row.component.html",
|
templateUrl: "vault-cipher-row.component.html",
|
||||||
})
|
})
|
||||||
export class VaultCipherRowComponent {
|
export class VaultCipherRowComponent implements OnInit {
|
||||||
protected RowHeightClass = RowHeightClass;
|
protected RowHeightClass = RowHeightClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag to determine if the extension refresh feature flag is enabled.
|
||||||
|
*/
|
||||||
|
protected extensionRefreshEnabled = false;
|
||||||
|
|
||||||
@Input() disabled: boolean;
|
@Input() disabled: boolean;
|
||||||
@Input() cipher: CipherView;
|
@Input() cipher: CipherView;
|
||||||
@Input() showOwner: boolean;
|
@Input() showOwner: boolean;
|
||||||
@ -36,6 +44,18 @@ export class VaultCipherRowComponent {
|
|||||||
|
|
||||||
protected CipherType = CipherType;
|
protected CipherType = CipherType;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook for component initialization.
|
||||||
|
* Checks if the extension refresh feature flag is enabled to provide to template.
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.extensionRefreshEnabled = await firstValueFrom(
|
||||||
|
this.configService.getFeatureFlag$(FeatureFlag.ExtensionRefresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected get showTotpCopyButton() {
|
protected get showTotpCopyButton() {
|
||||||
return (
|
return (
|
||||||
(this.cipher.login?.hasTotp ?? false) &&
|
(this.cipher.login?.hasTotp ?? false) &&
|
||||||
|
@ -109,6 +109,11 @@ import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/v
|
|||||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||||
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
||||||
import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component";
|
import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component";
|
||||||
|
import {
|
||||||
|
openViewCipherDialog,
|
||||||
|
ViewCipherDialogCloseResult,
|
||||||
|
ViewCipherDialogResult,
|
||||||
|
} from "./view.component";
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "VaultComponent";
|
const BroadcasterSubscriptionId = "VaultComponent";
|
||||||
const SearchTextDebounceInterval = 200;
|
const SearchTextDebounceInterval = 200;
|
||||||
@ -215,6 +220,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
cipherView.id = cipherId;
|
cipherView.id = cipherId;
|
||||||
if (params.action === "clone") {
|
if (params.action === "clone") {
|
||||||
await this.cloneCipher(cipherView);
|
await this.cloneCipher(cipherView);
|
||||||
|
} else if (params.action === "view") {
|
||||||
|
await this.viewCipher(cipherView);
|
||||||
} else if (params.action === "edit") {
|
} else if (params.action === "edit") {
|
||||||
await this.editCipher(cipherView);
|
await this.editCipher(cipherView);
|
||||||
}
|
}
|
||||||
@ -336,9 +343,14 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(() => this.route.queryParams),
|
switchMap(() => this.route.queryParams),
|
||||||
switchMap(async (params) => {
|
switchMap(async (params) => {
|
||||||
const cipherId = getCipherIdFromParams(params);
|
const cipherId = getCipherIdFromParams(params);
|
||||||
|
|
||||||
if (cipherId) {
|
if (cipherId) {
|
||||||
if ((await this.cipherService.get(cipherId)) != null) {
|
if (await this.cipherService.get(cipherId)) {
|
||||||
await this.editCipherId(cipherId);
|
if (params.action === "view") {
|
||||||
|
await this.viewCipherById(cipherId);
|
||||||
|
} else {
|
||||||
|
await this.editCipherId(cipherId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@ -626,7 +638,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
!(await this.passwordRepromptService.showPasswordPrompt())
|
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||||
) {
|
) {
|
||||||
// didn't pass password prompt, so don't open add / edit modal
|
// didn't pass password prompt, so don't open add / edit modal
|
||||||
this.go({ cipherId: null, itemId: null });
|
this.go({ cipherId: null, itemId: null, action: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -653,12 +665,64 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
modal.onClosedPromise().then(() => {
|
modal.onClosedPromise().then(() => {
|
||||||
this.go({ cipherId: null, itemId: null });
|
this.go({ cipherId: null, itemId: null, action: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
return childComponent;
|
return childComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
|
||||||
|
* @param cipher - CipherView
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
viewCipher(cipher: CipherView) {
|
||||||
|
return this.viewCipherById(cipher.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a cipher id and opens a dialog where it can be viewed.
|
||||||
|
* @param id - string
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
async viewCipherById(id: string) {
|
||||||
|
const cipher = await this.cipherService.get(id);
|
||||||
|
// If cipher exists (cipher is null when new) and MP reprompt
|
||||||
|
// is on for this cipher, then show password reprompt.
|
||||||
|
if (
|
||||||
|
cipher &&
|
||||||
|
cipher.reprompt !== 0 &&
|
||||||
|
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||||
|
) {
|
||||||
|
// Didn't pass password prompt, so don't open add / edit modal.
|
||||||
|
this.go({ cipherId: null, itemId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the cipher.
|
||||||
|
const cipherView = await cipher.decrypt(
|
||||||
|
await this.cipherService.getKeyForCipherKeyDecryption(cipher),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the dialog.
|
||||||
|
const dialogRef = openViewCipherDialog(this.dialogService, {
|
||||||
|
data: { cipher: cipherView },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the dialog to close.
|
||||||
|
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
|
// If the dialog was closed by deleting the cipher, refresh the vault.
|
||||||
|
if (result.action === ViewCipherDialogResult.deleted) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
|
||||||
|
if (!result.action) {
|
||||||
|
this.go({ cipherId: null, itemId: null, action: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async addCollection() {
|
async addCollection() {
|
||||||
const dialog = openCollectionDialog(this.dialogService, {
|
const dialog = openCollectionDialog(this.dialogService, {
|
||||||
data: {
|
data: {
|
||||||
|
@ -10,6 +10,7 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge
|
|||||||
import { PipesModule } from "./pipes/pipes.module";
|
import { PipesModule } from "./pipes/pipes.module";
|
||||||
import { VaultRoutingModule } from "./vault-routing.module";
|
import { VaultRoutingModule } from "./vault-routing.module";
|
||||||
import { VaultComponent } from "./vault.component";
|
import { VaultComponent } from "./vault.component";
|
||||||
|
import { ViewComponent } from "./view.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -23,6 +24,7 @@ import { VaultComponent } from "./vault.component";
|
|||||||
BulkDialogsModule,
|
BulkDialogsModule,
|
||||||
CollectionDialogModule,
|
CollectionDialogModule,
|
||||||
VaultComponent,
|
VaultComponent,
|
||||||
|
ViewComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class VaultModule {}
|
export class VaultModule {}
|
||||||
|
25
apps/web/src/app/vault/individual-vault/view.component.html
Normal file
25
apps/web/src/app/vault/individual-vault/view.component.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<bit-dialog dialogSize="large">
|
||||||
|
<span bitDialogTitle>
|
||||||
|
{{ cipherTypeString }}
|
||||||
|
</span>
|
||||||
|
<ng-container bitDialogContent>
|
||||||
|
<app-cipher-view [cipher]="cipher"></app-cipher-view>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button bitButton (click)="edit()" buttonType="primary" type="button" [disabled]="!cipher.edit">
|
||||||
|
{{ "edit" | i18n }}
|
||||||
|
</button>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<button
|
||||||
|
bitButton
|
||||||
|
type="button"
|
||||||
|
buttonType="danger"
|
||||||
|
[appA11yTitle]="'delete' | i18n"
|
||||||
|
[bitAction]="delete"
|
||||||
|
[disabled]="!cipher.edit"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</bit-dialog>
|
117
apps/web/src/app/vault/individual-vault/view.component.spec.ts
Normal file
117
apps/web/src/app/vault/individual-vault/view.component.spec.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||||
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { ViewComponent, ViewCipherDialogParams, ViewCipherDialogResult } from "./view.component";
|
||||||
|
|
||||||
|
describe("ViewComponent", () => {
|
||||||
|
let component: ViewComponent;
|
||||||
|
let fixture: ComponentFixture<ViewComponent>;
|
||||||
|
let router: Router;
|
||||||
|
|
||||||
|
const mockCipher: CipherView = {
|
||||||
|
id: "cipher-id",
|
||||||
|
type: 1,
|
||||||
|
organizationId: "org-id",
|
||||||
|
isDeleted: false,
|
||||||
|
} as CipherView;
|
||||||
|
|
||||||
|
const mockOrganization: Organization = {
|
||||||
|
id: "org-id",
|
||||||
|
name: "Test Organization",
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const mockParams: ViewCipherDialogParams = {
|
||||||
|
cipher: mockCipher,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ViewComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: DIALOG_DATA, useValue: mockParams },
|
||||||
|
{ provide: DialogRef, useValue: mock<DialogRef>() },
|
||||||
|
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } },
|
||||||
|
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||||
|
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||||
|
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||||
|
{ provide: MessagingService, useValue: mock<MessagingService>() },
|
||||||
|
{ provide: LogService, useValue: mock<LogService>() },
|
||||||
|
{
|
||||||
|
provide: OrganizationService,
|
||||||
|
useValue: { get: jest.fn().mockResolvedValue(mockOrganization) },
|
||||||
|
},
|
||||||
|
{ provide: Router, useValue: mock<Router>() },
|
||||||
|
{ provide: CollectionService, useValue: mock<CollectionService>() },
|
||||||
|
{ provide: FolderService, useValue: mock<FolderService>() },
|
||||||
|
{ provide: CryptoService, useValue: mock<CryptoService>() },
|
||||||
|
{
|
||||||
|
provide: BillingAccountProfileStateService,
|
||||||
|
useValue: mock<BillingAccountProfileStateService>(),
|
||||||
|
},
|
||||||
|
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ViewComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
component.params = mockParams;
|
||||||
|
component.cipher = mockCipher;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ngOnInit", () => {
|
||||||
|
it("initializes the component with cipher and organization", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component.cipher).toEqual(mockCipher);
|
||||||
|
expect(component.organization).toEqual(mockOrganization);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edit", () => {
|
||||||
|
it("navigates to the edit route and closes the dialog with the proper arguments", async () => {
|
||||||
|
jest.spyOn(router, "navigate").mockResolvedValue(true);
|
||||||
|
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||||
|
|
||||||
|
await component.edit();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||||
|
queryParams: {
|
||||||
|
itemId: mockCipher.id,
|
||||||
|
action: "edit",
|
||||||
|
organizationId: mockCipher.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete", () => {
|
||||||
|
it("calls the delete method on delete and closes the dialog with the proper arguments", async () => {
|
||||||
|
const deleteSpy = jest.spyOn(component, "delete");
|
||||||
|
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||||
|
jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
|
||||||
|
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(deleteSpy).toHaveBeenCalled();
|
||||||
|
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
193
apps/web/src/app/vault/individual-vault/view.component.ts
Normal file
193
apps/web/src/app/vault/individual-vault/view.component.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { Subject } from "rxjs";
|
||||||
|
|
||||||
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
DialogModule,
|
||||||
|
DialogService,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
|
||||||
|
import { SharedModule } from "../../shared/shared.module";
|
||||||
|
|
||||||
|
export interface ViewCipherDialogParams {
|
||||||
|
cipher: CipherView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ViewCipherDialogResult {
|
||||||
|
edited = "edited",
|
||||||
|
deleted = "deleted",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewCipherDialogCloseResult {
|
||||||
|
action: ViewCipherDialogResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for viewing a cipher, presented in a dialog.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: "app-vault-view",
|
||||||
|
templateUrl: "view.component.html",
|
||||||
|
standalone: true,
|
||||||
|
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
|
||||||
|
})
|
||||||
|
export class ViewComponent implements OnInit, OnDestroy {
|
||||||
|
cipher: CipherView;
|
||||||
|
onDeletedCipher = new EventEmitter<CipherView>();
|
||||||
|
cipherTypeString: string;
|
||||||
|
cipherEditUrl: string;
|
||||||
|
organization: Organization;
|
||||||
|
restrictProviderAccess = false;
|
||||||
|
|
||||||
|
protected destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DIALOG_DATA) public params: ViewCipherDialogParams,
|
||||||
|
private dialogRef: DialogRef<ViewCipherDialogCloseResult>,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private messagingService: MessagingService,
|
||||||
|
private logService: LogService,
|
||||||
|
private cipherService: CipherService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private organizationService: OrganizationService,
|
||||||
|
private router: Router,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook for component initialization.
|
||||||
|
*/
|
||||||
|
async ngOnInit() {
|
||||||
|
this.cipher = this.params.cipher;
|
||||||
|
this.cipherTypeString = this.getCipherViewTypeString();
|
||||||
|
if (this.cipher.organizationId) {
|
||||||
|
this.organization = await this.organizationService.get(this.cipher.organizationId);
|
||||||
|
}
|
||||||
|
this.restrictProviderAccess = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.RestrictProviderAccess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook for component destruction.
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to handle cipher deletion. Called when a user clicks the delete button.
|
||||||
|
*/
|
||||||
|
delete = async () => {
|
||||||
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
title: { key: "deleteItem" },
|
||||||
|
content: {
|
||||||
|
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
|
||||||
|
},
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.deleteCipher();
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: this.i18nService.t("success"),
|
||||||
|
message: this.i18nService.t(
|
||||||
|
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
this.onDeletedCipher.emit(this.cipher);
|
||||||
|
this.messagingService.send(
|
||||||
|
this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.logService.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialogRef.close({ action: ViewCipherDialogResult.deleted });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to delete cipher.
|
||||||
|
*/
|
||||||
|
protected async deleteCipher(): Promise<void> {
|
||||||
|
const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess);
|
||||||
|
if (this.cipher.isDeleted) {
|
||||||
|
await this.cipherService.deleteWithServer(this.cipher.id, asAdmin);
|
||||||
|
} else {
|
||||||
|
await this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to handle cipher editing. Called when a user clicks the edit button.
|
||||||
|
*/
|
||||||
|
async edit(): Promise<void> {
|
||||||
|
this.dialogRef.close({ action: ViewCipherDialogResult.edited });
|
||||||
|
await this.router.navigate([], {
|
||||||
|
queryParams: {
|
||||||
|
itemId: this.cipher.id,
|
||||||
|
action: "edit",
|
||||||
|
organizationId: this.cipher.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to get cipher view type string, used for the dialog title.
|
||||||
|
* E.g. "View login" or "View note".
|
||||||
|
* @returns The localized string for the cipher type
|
||||||
|
*/
|
||||||
|
getCipherViewTypeString(): string {
|
||||||
|
if (!this.cipher) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.cipher.type) {
|
||||||
|
case CipherType.Login:
|
||||||
|
return this.i18nService.t("viewItemType", this.i18nService.t("typeLogin").toLowerCase());
|
||||||
|
case CipherType.SecureNote:
|
||||||
|
return this.i18nService.t("viewItemType", this.i18nService.t("note").toLowerCase());
|
||||||
|
case CipherType.Card:
|
||||||
|
return this.i18nService.t("viewItemType", this.i18nService.t("typeCard").toLowerCase());
|
||||||
|
case CipherType.Identity:
|
||||||
|
return this.i18nService.t("viewItemType", this.i18nService.t("typeIdentity").toLowerCase());
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strongly typed helper to open a cipher view dialog
|
||||||
|
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||||
|
* @param config Configuration for the dialog
|
||||||
|
* @returns A reference to the opened dialog
|
||||||
|
*/
|
||||||
|
export function openViewCipherDialog(
|
||||||
|
dialogService: DialogService,
|
||||||
|
config: DialogConfig<ViewCipherDialogParams>,
|
||||||
|
): DialogRef<ViewCipherDialogCloseResult> {
|
||||||
|
return dialogService.open(ViewComponent, config);
|
||||||
|
}
|
@ -89,6 +89,12 @@ import {
|
|||||||
RoutedVaultFilterModel,
|
RoutedVaultFilterModel,
|
||||||
Unassigned,
|
Unassigned,
|
||||||
} from "../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
} from "../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||||
|
import {
|
||||||
|
openViewCipherDialog,
|
||||||
|
ViewCipherDialogCloseResult,
|
||||||
|
ViewCipherDialogResult,
|
||||||
|
ViewComponent,
|
||||||
|
} from "../individual-vault/view.component";
|
||||||
import { VaultHeaderComponent } from "../org-vault/vault-header/vault-header.component";
|
import { VaultHeaderComponent } from "../org-vault/vault-header/vault-header.component";
|
||||||
import { getNestedCollectionTree } from "../utils/collection-utils";
|
import { getNestedCollectionTree } from "../utils/collection-utils";
|
||||||
|
|
||||||
@ -121,6 +127,7 @@ enum AddAccessStatusType {
|
|||||||
VaultItemsModule,
|
VaultItemsModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
NoItemsModule,
|
NoItemsModule,
|
||||||
|
ViewComponent,
|
||||||
],
|
],
|
||||||
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
|
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
|
||||||
})
|
})
|
||||||
@ -517,7 +524,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
(await firstValueFrom(allCipherMap$))[cipherId] != undefined;
|
(await firstValueFrom(allCipherMap$))[cipherId] != undefined;
|
||||||
|
|
||||||
if (canEditCipher) {
|
if (canEditCipher) {
|
||||||
await this.editCipherId(cipherId);
|
if (qParams.action === "view") {
|
||||||
|
await this.viewCipherById(cipherId);
|
||||||
|
} else {
|
||||||
|
await this.editCipherId(cipherId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@ -848,12 +859,64 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
modal.onClosedPromise().then(() => {
|
modal.onClosedPromise().then(() => {
|
||||||
this.go({ cipherId: null, itemId: null });
|
this.go({ cipherId: null, itemId: null, action: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
return childComponent;
|
return childComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
|
||||||
|
* @param cipher - CipherView
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
viewCipher(cipher: CipherView) {
|
||||||
|
return this.viewCipherById(cipher.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a cipher id and opens a dialog where it can be viewed.
|
||||||
|
* @param id - string
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
async viewCipherById(id: string) {
|
||||||
|
const cipher = await this.cipherService.get(id);
|
||||||
|
// if cipher exists (cipher is null when new) and MP reprompt
|
||||||
|
// is on for this cipher, then show password reprompt.
|
||||||
|
if (
|
||||||
|
cipher &&
|
||||||
|
cipher.reprompt !== 0 &&
|
||||||
|
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||||
|
) {
|
||||||
|
// didn't pass password prompt, so don't open add / edit modal.
|
||||||
|
this.go({ cipherId: null, itemId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the cipher.
|
||||||
|
const cipherView = await cipher.decrypt(
|
||||||
|
await this.cipherService.getKeyForCipherKeyDecryption(cipher),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the dialog.
|
||||||
|
const dialogRef = openViewCipherDialog(this.dialogService, {
|
||||||
|
data: { cipher: cipherView },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the dialog to close.
|
||||||
|
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
|
// If the dialog was closed by deleting the cipher, refresh the vault.
|
||||||
|
if (result.action === ViewCipherDialogResult.deleted) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
|
||||||
|
if (!result.action) {
|
||||||
|
this.go({ cipherId: null, itemId: null, action: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async cloneCipher(cipher: CipherView) {
|
async cloneCipher(cipher: CipherView) {
|
||||||
if (cipher.login?.hasFido2Credentials) {
|
if (cipher.login?.hasFido2Credentials) {
|
||||||
const confirmed = await this.dialogService.openSimpleDialog({
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
@ -4,6 +4,7 @@ import { LooseComponentsModule } from "../../shared/loose-components.module";
|
|||||||
import { SharedModule } from "../../shared/shared.module";
|
import { SharedModule } from "../../shared/shared.module";
|
||||||
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
||||||
import { CollectionDialogModule } from "../components/collection-dialog";
|
import { CollectionDialogModule } from "../components/collection-dialog";
|
||||||
|
import { ViewComponent } from "../individual-vault/view.component";
|
||||||
|
|
||||||
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
|
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
|
||||||
import { GroupBadgeModule } from "./group-badge/group-badge.module";
|
import { GroupBadgeModule } from "./group-badge/group-badge.module";
|
||||||
@ -20,6 +21,7 @@ import { VaultComponent } from "./vault.component";
|
|||||||
OrganizationBadgeModule,
|
OrganizationBadgeModule,
|
||||||
CollectionDialogModule,
|
CollectionDialogModule,
|
||||||
VaultComponent,
|
VaultComponent,
|
||||||
|
ViewComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class VaultModule {}
|
export class VaultModule {}
|
||||||
|
@ -475,6 +475,15 @@
|
|||||||
"viewItem": {
|
"viewItem": {
|
||||||
"message": "View item"
|
"message": "View item"
|
||||||
},
|
},
|
||||||
|
"viewItemType": {
|
||||||
|
"message": "View $ITEMTYPE$",
|
||||||
|
"placeholders": {
|
||||||
|
"itemtype": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"new": {
|
"new": {
|
||||||
"message": "New",
|
"message": "New",
|
||||||
"description": "for adding new items"
|
"description": "for adding new items"
|
||||||
|
Loading…
Reference in New Issue
Block a user