1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-7161] browser v2 view container (#9723)

* Build new view-v2 component and reusable view sections. Custom Fields, Item Details, Attachments, Additional Info,  Item History
This commit is contained in:
Jason Ng
2024-07-10 00:11:51 -04:00
committed by GitHub
parent 7dfef8991c
commit 6d6785297b
26 changed files with 846 additions and 8 deletions

View File

@@ -1,16 +1,32 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Directive, HostListener, Input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
@Directive({
selector: "[appCopyClick]",
})
export class CopyClickDirective {
constructor(private platformUtilsService: PlatformUtilsService) {}
constructor(
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
@Input("appCopyClick") valueToCopy = "";
@Input({ transform: coerceBooleanProperty }) showToast?: boolean;
@HostListener("click") onClick() {
this.platformUtilsService.copyToClipboard(this.valueToCopy);
if (this.showToast) {
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("copySuccessful"),
});
}
}
}

View File

@@ -17,6 +17,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde
clearCache: () => Promise<void>;
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
getDecrypted$: (id: string) => Observable<FolderView>;
getAllFromState: () => Promise<Folder[]>;
/**
* @deprecated Only use in CLI!

View File

@@ -1,4 +1,4 @@
import { Observable, firstValueFrom, map } from "rxjs";
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
@@ -61,6 +61,13 @@ export class FolderService implements InternalFolderServiceAbstraction {
return folders.find((folder) => folder.id === id);
}
getDecrypted$(id: string): Observable<FolderView | undefined> {
return this.folderViews$.pipe(
map((folders) => folders.find((folder) => folder.id === id)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
async getAllFromState(): Promise<Folder[]> {
return await firstValueFrom(this.folders$);
}

View File

@@ -0,0 +1,21 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "additionalInformation" | i18n }}</h2>
</bit-section-header>
<bit-card>
<label class="tw-text-xs tw-text-muted tw-select-none">
{{ "note" | i18n }}
</label>
<div class="tw-flex tw-justify-between">
<textarea readonly bitInput aria-readonly="true">{{ notes }}</textarea>
<button
bitIconButton="bwi-clone"
size="small"
type="button"
[appCopyClick]="notes"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</div>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,29 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
IconButtonModule,
CardComponent,
InputModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
@Component({
selector: "app-additional-information",
templateUrl: "additional-information.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CardComponent,
IconButtonModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
],
})
export class AdditionalInformationComponent {
@Input() notes: string;
}

View File

@@ -0,0 +1,32 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "attachments" | i18n }}</h2>
</bit-section-header>
<bit-item-group>
<bit-item *ngFor="let attachment of cipher.attachments">
<div slot="start" class="tw-py-4 tw-px-3">
<h3>
{{ attachment.fileName }}
</h3>
<div class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
{{ attachment.sizeName }}
</div>
</div>
<div class="tw-flex tw-items-center" (click)="downloadAttachment(attachment)" slot="end">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[appA11yTitle]="'downloadAttachment' | i18n: attachment.fileName"
*ngIf="!$any(attachment).downloading"
></button>
<button
type="button"
bitIconButton="bwi-spinner bwi-spin"
size="small"
*ngIf="$any(attachment).downloading"
></button>
</div>
</bit-item>
</bit-item-group>
</bit-section>

View File

@@ -0,0 +1,153 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NEVER, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ToastService,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@Component({
selector: "app-attachments-v2",
templateUrl: "attachments-v2.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
],
})
export class AttachmentsV2Component {
@Input() cipher: CipherView;
canAccessPremium: boolean;
orgKey: OrgKey;
private passwordReprompted = false;
constructor(
private passwordRepromptService: PasswordRepromptService,
private i18nService: I18nService,
private apiService: ApiService,
private fileDownloadService: FileDownloadService,
private cryptoService: CryptoService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private stateProvider: StateProvider,
private encryptService: EncryptService,
) {
this.subscribeToHasPremiumCheck();
this.subscribeToOrgKey();
}
subscribeToHasPremiumCheck() {
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntilDestroyed())
.subscribe((data) => {
this.canAccessPremium = data;
});
}
subscribeToOrgKey() {
this.stateProvider.activeUserId$
.pipe(
switchMap((userId) => (userId != null ? this.cryptoService.orgKeys$(userId) : NEVER)),
takeUntilDestroyed(),
)
.subscribe((data: Record<OrganizationId, OrgKey> | null) => {
if (data) {
this.orgKey = data[this.cipher.organizationId as OrganizationId];
}
});
}
async downloadAttachment(attachment: any) {
this.passwordReprompted =
this.passwordReprompted ||
(await this.passwordRepromptService.passwordRepromptCheck(this.cipher));
if (!this.passwordReprompted) {
return;
}
const file = attachment as any;
if (file.downloading) {
return;
}
if (this.cipher.organizationId == null && !this.canAccessPremium) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("premiumRequired"),
message: this.i18nService.t("premiumRequiredDesc"),
});
return;
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
this.cipher.id,
attachment.id,
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
throw e;
}
}
file.downloading = true;
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
file.downloading = false;
return;
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key = attachment.key != null ? attachment.key : this.orgKey;
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
});
} catch (e) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
file.downloading = false;
}
}

View File

@@ -0,0 +1,28 @@
<ng-container *ngIf="!!cipher">
<!-- ITEM DETAILS -->
<app-item-details-v2
[cipher]="cipher"
[organization]="organization$ | async"
[collections]="collections$ | async"
[folder]="folder$ | async"
>
</app-item-details-v2>
<!-- ADDITIONAL INFORMATION -->
<ng-container *ngIf="cipher.notes">
<app-additional-information [notes]="cipher.notes"> </app-additional-information>
</ng-container>
<!-- CUSTOM FIELDS -->
<ng-container *ngIf="cipher.fields">
<app-custom-fields-v2 [fields]="cipher.fields"> </app-custom-fields-v2>
</ng-container>
<!-- ATTACHMENTS SECTION -->
<ng-container *ngIf="cipher.attachments">
<app-attachments-v2 [cipher]="cipher"> </app-attachments-v2>
</ng-container>
<!-- ITEM HISTORY SECTION -->
<app-item-history-v2 [cipher]="cipher"> </app-item-history-v2>
</ng-container>

View File

@@ -0,0 +1,84 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { Observable, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionId } from "@bitwarden/common/types/guid";
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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { SearchModule } from "@bitwarden/components";
import { PopupFooterComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-page.component";
import { AdditionalInformationComponent } from "./additional-information/additional-information.component";
import { AttachmentsV2Component } from "./attachments/attachments-v2.component";
import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component";
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
@Component({
selector: "app-cipher-view",
templateUrl: "cipher-view.component.html",
standalone: true,
imports: [
CommonModule,
SearchModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
ItemDetailsV2Component,
AdditionalInformationComponent,
AttachmentsV2Component,
ItemHistoryV2Component,
CustomFieldV2Component,
],
})
export class CipherViewComponent implements OnInit {
@Input() cipher: CipherView;
organization$: Observable<Organization>;
folder$: Observable<FolderView>;
collections$: Observable<CollectionView[]>;
private destroyed$: Subject<void> = new Subject();
constructor(
private organizationService: OrganizationService,
private collectionService: CollectionService,
private folderService: FolderService,
) {}
async ngOnInit() {
await this.loadCipherData();
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
async loadCipherData() {
if (this.cipher.collectionIds.length > 0) {
this.collections$ = this.collectionService
.decryptedCollectionViews$(this.cipher.collectionIds as CollectionId[])
.pipe(takeUntil(this.destroyed$));
}
if (this.cipher.organizationId) {
this.organization$ = this.organizationService
.get$(this.cipher.organizationId)
.pipe(takeUntil(this.destroyed$));
}
if (this.cipher.folderId) {
this.folder$ = this.folderService
.getDecrypted$(this.cipher.folderId)
.pipe(takeUntil(this.destroyed$));
}
}
}

View File

@@ -0,0 +1,76 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "customFields" | i18n }}</h2>
</bit-section-header>
<bit-card>
<div
class="tw-mb-4 tw-border-secondary-300 tw-bg-background"
*ngFor="let field of fields; let last = last"
[ngClass]="{ 'tw-border-0 tw-border-b tw-border-solid tw-pb-2 tw-mb-4': !last }"
>
<ng-container *ngIf="field.type === fieldType.Text">
<label class="tw-text-xs tw-text-muted tw-select-none">
{{ field.name }}
</label>
<div class="tw-flex tw-justify-between">
<input readonly bitInput type="text" [value]="field.value" aria-readonly="true" />
<button
bitIconButton="bwi-clone"
size="small"
type="button"
[appCopyClick]="field.value"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</div>
</ng-container>
<ng-container *ngIf="field.type === fieldType.Hidden">
<label class="tw-text-xs tw-text-muted tw-select-none">
{{ field.name }}
</label>
<bit-form-field>
<input readonly bitInput type="password" [value]="field.value" aria-readonly="true" />
<button type="button" bitIconButton bitPasswordInputToggle></button>
<button
bitIconButton="bwi-clone"
size="small"
type="button"
[appCopyClick]="field.value"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</bit-form-field>
</ng-container>
<ng-container *ngIf="field.type === fieldType.Boolean">
<div class="tw-flex tw-my-2">
<input type="checkbox" [value]="field.value" readonly aria-readonly="true" />
<h5 class="tw-ml-3">
{{ field.name }}
</h5>
</div>
</ng-container>
<ng-container *ngIf="field.type === fieldType.Linked">
<label class="tw-text-xs tw-text-muted tw-select-none">
{{ "linked" | i18n }}: {{ field.name }}
</label>
<div class="tw-flex tw-justify-between">
<input
readonly
bitInput
type="text"
[value]="getLinkedType(field.linkedId)"
aria-readonly="true"
/>
<button
bitIconButton="bwi-clone"
size="small"
type="button"
[appCopyClick]="field.name"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</div>
</ng-container>
</div>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,47 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FieldType, LinkedIdType, LoginLinkedId } from "@bitwarden/common/vault/enums";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import {
CardComponent,
IconButtonModule,
FormFieldModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
@Component({
selector: "app-custom-fields-v2",
templateUrl: "custom-fields-v2.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CardComponent,
IconButtonModule,
FormFieldModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
],
})
export class CustomFieldV2Component {
@Input() fields: FieldView[];
fieldType = FieldType;
constructor(private i18nService: I18nService) {}
getLinkedType(linkedId: LinkedIdType) {
if (linkedId === LoginLinkedId.Username) {
return this.i18nService.t("username");
}
if (linkedId === LoginLinkedId.Password) {
return this.i18nService.t("password");
}
}
}

View File

@@ -0,0 +1 @@
export * from "./cipher-view.component";

View File

@@ -0,0 +1,33 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
</bit-section-header>
<bit-card>
<div class="tw-pb-2 tw-mb-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300">
<label class="tw-block tw-w-full tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
{{ "itemName" | i18n }}
</label>
<input readonly bitInput type="text" [value]="cipher.login.username" aria-readonly="true" />
</div>
<div *ngIf="cipher.collectionIds || cipher.organizationId || cipher.folderId">
<div *ngIf="!cipher.organizationId" [ngClass]="{ 'tw-mb-3': cipher.collectionIds }">
<i class="bwi bwi-user bwi-lg bwi-fw"></i> {{ "ownerYou" | i18n }}
</div>
<div
*ngIf="cipher.organizationId && organization"
[ngClass]="{ 'tw-mb-3': cipher.collectionIds }"
>
<i class="bwi bwi-business bwi-lg bwi-fw"></i> {{ organization.name }}
</div>
<div *ngIf="cipher.collectionIds && collections" [ngClass]="{ 'tw-mb-3': cipher.folderId }">
<h3 *ngFor="let collection of collections">
<i class="bwi bwi-collection bwi-lg bwi-fw"></i> {{ collection.name }}
</h3>
</div>
<div *ngIf="cipher.folderId && folder">
<i class="bwi bwi-folder bwi-lg bwi-fw"></i> {{ folder.name }}
</div>
</div>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,22 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CardComponent, SectionComponent, SectionHeaderComponent } from "@bitwarden/components";
@Component({
selector: "app-item-details-v2",
templateUrl: "item-details-v2.component.html",
standalone: true,
imports: [CommonModule, JslibModule, CardComponent, SectionComponent, SectionHeaderComponent],
})
export class ItemDetailsV2Component {
@Input() cipher: CipherView;
@Input() organization?: Organization;
@Input() collections?: CollectionView[];
@Input() folder?: FolderView;
}

View File

@@ -0,0 +1,27 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "itemHistory" | i18n }}</h2>
</bit-section-header>
<bit-card>
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
<span class="tw-font-bold">{{ "lastEdited" | i18n }}:</span>
{{ cipher.revisionDate | date: "medium" }}
</p>
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
<span class="tw-font-bold">{{ "dateCreated" | i18n }}:</span>
{{ cipher.creationDate | date: "medium" }}
</p>
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
<span class="tw-font-bold">{{ "datePasswordUpdated" | i18n }}:</span>
{{ cipher.passwordRevisionDisplayDate | date: "medium" }}
</p>
<a
*ngIf="cipher.hasPasswordHistory"
class="tw-font-bold tw-no-underline"
routerLink="/cipher-password-history"
[queryParams]="{ cipherId: cipher.id }"
>
{{ "passwordHistory" | i18n }}
</a>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,24 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CardComponent, SectionComponent, SectionHeaderComponent } from "@bitwarden/components";
@Component({
selector: "app-item-history-v2",
templateUrl: "item-history-v2.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
RouterModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
],
})
export class ItemHistoryV2Component {
@Input() cipher: CipherView;
}

View File

@@ -2,4 +2,5 @@ export { PasswordRepromptService } from "./services/password-reprompt.service";
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
export * from "./cipher-view";
export * from "./cipher-form";

View File

@@ -2,6 +2,8 @@ import { Injectable } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptComponent } from "../components/password-reprompt.component";
@@ -21,6 +23,14 @@ export class PasswordRepromptService {
return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"];
}
async passwordRepromptCheck(cipher: CipherView) {
if (cipher.reprompt === CipherRepromptType.None) {
return true;
}
return await this.showPasswordPrompt();
}
async showPasswordPrompt() {
if (!(await this.enabled())) {
return true;