1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-28 12:45:45 +01:00

[EC-886] Add functionality to vault header when viewing collections (#4602)

* [EC-886] Fix i18n key in vault filter section

* [EC-886] Add shared functionality to vault-filter model

Common patterns for determining the current filter status are used in the vault header, filter, and items components that could be extracted to the filter model to be consistent and less repetitive.

* [EC-886] Add the individual vault header component

Create a vault header component for encapsulation and to reduce the complexity of the vault component.

* [EC-886] Add the organizational vault header component

Create a vault header component for encapsulation and to reduce the complexity of the organizational vault component.

* [EC-886] Use the new vault header component in the individual vault

- Remove the old header template from the vault component and introduce the <app-vault-header> component instead.
- Remove redundant logic from vault component that was moved to the header component and/or vault filter model.

* [EC-886] Use the new vault header component in the organization vault

- Remove the old header template from the org vault component and introduce the <app-org-vault-header> component instead.
- Remove redundant logic from vault component that was moved to the header component and/or vault filter model.

* [EC-886] Adjust vault header to make the word "vault" lowercase

* [EC-886] Top align vault header to prevent button jumping

* [EC-886] Center align collection icon/button with header text
This commit is contained in:
Shane Melton 2023-02-02 13:19:48 -08:00 committed by GitHub
parent 37a374713c
commit d3539a4a44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 555 additions and 220 deletions

View File

@ -0,0 +1,112 @@
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
<div>
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
<bit-breadcrumb
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
>
<!-- First node in the tree is the "Org Name Vault" item. The rest come from user input. -->
<ng-container *ngIf="first">
{{ activeOrganizationId | orgNameFromId: (organizations$ | async) }}
{{ "vault" | i18n | lowercase }}
</ng-container>
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
</bit-breadcrumb>
</bit-breadcrumbs>
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
<i
*ngIf="activeFilter.isCollectionSelected"
class="bwi bwi-collection"
aria-hidden="true"
></i>
<span>{{ title }}</span>
<ng-container
*ngIf="activeFilter.isCollectionSelected && !activeFilter.isUnassignedCollectionSelected"
>
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
aria-haspopup
></button>
<bit-menu #editCollectionMenu>
<button
type="button"
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
bitMenuItem
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'info')"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
bitMenuItem
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'access')"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
type="button"
*ngIf="canDeleteCollection(activeFilter.selectedCollectionNode.node)"
bitMenuItem
(click)="deleteCollection(activeFilter.selectedCollectionNode.node)"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
<small #actionSpinner [appApiAction]="actionPromise">
<ng-container *ngIf="$any(actionSpinner).loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
</small>
</h1>
</div>
<div *ngIf="!activeFilter.isDeleted" class="tw-shrink-0">
<div *ngIf="organization.canCreateNewCollections" appListDropdown>
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="addOptions"
id="newItemDropdown"
appA11yTitle="{{ 'new' | i18n }}"
>
{{ "new" | i18n }}<i class="bwi bwi-angle-down tw-ml-2" aria-hidden="true"></i>
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
<button type="button" bitMenuItem (click)="addCipher()">
<i class="bwi bwi-fw bwi-globe" aria-hidden="true"></i>
{{ "item" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCollection()">
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collection" | i18n }}
</button>
</bit-menu>
</div>
<button
*ngIf="!organization.canCreateNewCollections"
type="button"
bitButton
buttonType="primary"
(click)="addCipher()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "newItem" | i18n }}
</button>
</div>
</div>

View File

@ -0,0 +1,243 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ProductType } from "@bitwarden/common/enums/productType";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import {
DialogService,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/components";
import { VaultFilterService } from "../../../../vault/app/vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../../../vault/app/vault/vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "../../../../vault/app/vault/vault-filter/shared/models/vault-filter.type";
import { CollectionAdminService, CollectionAdminView } from "../../core";
import {
CollectionDialogResult,
CollectionDialogTabType,
openCollectionDialog,
} from "../../shared";
@Component({
selector: "app-org-vault-header",
templateUrl: "./vault-header.component.html",
})
export class VaultHeaderComponent {
/**
* The organization currently being viewed
*/
@Input() organization: Organization;
/**
* Promise that is used to determine the loading state of the header via the ApiAction directive.
* When the promise exists and is not resolved, the loading spinner will be shown.
*/
@Input() actionPromise: Promise<any>;
/**
* The filter being actively applied to the vault view
*/
@Input() activeFilter: VaultFilter;
/**
* Emits when the active filter has been modified by the header
*/
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
/**
* Emits an event when a collection is modified or deleted via the header collection dropdown menu
*/
@Output() onCollectionChanged = new EventEmitter<CollectionView | null>();
/**
* Emits an event when the new item button is clicked in the header
*/
@Output() onAddCipher = new EventEmitter<void>();
protected organizations$ = this.organizationService.organizations$;
constructor(
private organizationService: OrganizationService,
private i18nService: I18nService,
private dialogService: DialogService,
private vaultFilterService: VaultFilterService,
private platformUtilsService: PlatformUtilsService,
private apiService: ApiService,
private logService: LogService,
private collectionAdminService: CollectionAdminService,
private router: Router
) {}
/**
* The id of the organization that is currently being filtered on.
* This can come from a collection filter, organization filter, or the current organization when viewed
* in the organization admin console and no other filters are applied.
*/
get activeOrganizationId() {
if (this.activeFilter.selectedCollectionNode != null) {
return this.activeFilter.selectedCollectionNode.node.organizationId;
}
if (this.activeFilter.selectedOrganizationNode != null) {
return this.activeFilter.selectedOrganizationNode.node.id;
}
return this.organization.id;
}
get title() {
if (this.activeFilter.isCollectionSelected) {
return this.activeFilter.selectedCollectionNode.node.name;
}
if (this.activeFilter.isUnassignedCollectionSelected) {
return this.i18nService.t("unassigned");
}
return `${this.organization.name} ${this.i18nService.t("vault").toLowerCase()}`;
}
private showFreeOrgUpgradeDialog(): void {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canManageBilling
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
this.organization.maxCollections
),
type: SimpleDialogType.PRIMARY,
};
if (this.organization.canManageBilling) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts);
firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => {
if (!result) {
return;
}
if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) {
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
}
applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.activeFilterChanged.emit(filter);
}
canEditCollection(c: CollectionAdminView): boolean {
// Only edit collections if we're in the org vault and not editing "Unassigned"
if (this.organization === undefined || c.id === null) {
return false;
}
// Otherwise, check if we can edit the specified collection
return (
this.organization.canEditAnyCollection ||
(this.organization.canEditAssignedCollections && c.assigned)
);
}
addCipher() {
this.onAddCipher.emit();
}
async addCollection() {
if (this.organization.planProductType === ProductType.Free) {
const collections = await this.collectionAdminService.getAll(this.organization.id);
if (collections.length === this.organization.maxCollections) {
this.showFreeOrgUpgradeDialog();
return;
}
}
const dialog = openCollectionDialog(this.dialogService, {
data: {
organizationId: this.organization?.id,
parentCollectionId: this.activeFilter.collectionId,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.onCollectionChanged.emit(null);
}
}
async editCollection(c: CollectionView, tab: "info" | "access"): Promise<void> {
const tabType = tab == "info" ? CollectionDialogTabType.Info : CollectionDialogTabType.Access;
const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tabType },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.onCollectionChanged.emit(c);
}
}
canDeleteCollection(c: CollectionAdminView): boolean {
// Only delete collections if we're in the org vault and not deleting "Unassigned"
if (this.organization === undefined || c.id === null) {
return false;
}
// Otherwise, check if we can delete the specified collection
return (
this.organization?.canDeleteAnyCollection ||
(this.organization?.canDeleteAssignedCollections && c.assigned)
);
}
async deleteCollection(collection: CollectionView): Promise<void> {
if (!this.organization.canDeleteAssignedCollections) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions")
);
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
collection.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return;
}
try {
this.actionPromise = this.apiService.deleteCollection(this.organization?.id, collection.id);
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", collection.name)
);
this.onCollectionChanged.emit(collection);
} catch (e) {
this.logService.error(e);
}
}
}

View File

@ -18,65 +18,14 @@
</div> </div>
</div> </div>
<div class="col-9"> <div class="col-9">
<bit-breadcrumbs *ngIf="breadcrumbs.length > 0"> <app-org-vault-header
<bit-breadcrumb [activeFilter]="activeFilter"
*ngFor="let collection of breadcrumbs; let first = first" (activeFilterChanged)="applyVaultFilter($event)"
[icon]="first ? undefined : 'bwi-collection'" (onCollectionChanged)="refreshItems()"
(click)="applyCollectionFilter(collection)" [actionPromise]="vaultItemsComponent.actionPromise"
> (onAddCipher)="addCipher()"
<!-- First node in the tree contains a translation key. The rest come from user input. --> [organization]="organization"
<ng-container *ngIf="first">{{ collection.node.name | i18n }}</ng-container> ></app-org-vault-header>
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
</bit-breadcrumb>
</bit-breadcrumbs>
<div class="tw-mb-4 tw-flex">
<h1>
{{ "vaultItems" | i18n }}
<small #actionSpinner [appApiAction]="vaultItemsComponent.actionPromise">
<ng-container *ngIf="$any(actionSpinner).loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
</small>
</h1>
<div *ngIf="!activeFilter.isDeleted" class="ml-auto d-flex">
<div *ngIf="organization.canCreateNewCollections" class="dropdown mr-2" appListDropdown>
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="addOptions"
id="newItemDropdown"
appA11yTitle="{{ 'new' | i18n }}"
>
{{ "new" | i18n }}<i class="bwi bwi-angle-down tw-ml-2" aria-hidden="true"></i>
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
<button type="button" bitMenuItem (click)="addCipher()">
<i class="bwi bwi-fw bwi-globe" aria-hidden="true"></i>
{{ "item" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCollection()">
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collection" | i18n }}
</button>
</bit-menu>
</div>
<button
*ngIf="!organization?.canCreateNewCollections"
type="button"
bitButton
buttonType="primary"
(click)="addCipher()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "newItem" | i18n }}
</button>
</div>
</div>
<app-callout <app-callout
type="warning" type="warning"
*ngIf="activeFilter.isDeleted" *ngIf="activeFilter.isDeleted"

View File

@ -8,7 +8,7 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router"; import { ActivatedRoute, Params, Router } from "@angular/router";
import { combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs"; import { combineLatest, firstValueFrom, Subject } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators"; import { first, switchMap, takeUntil } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -17,29 +17,16 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ProductType } from "@bitwarden/common/enums/productType";
import { Organization } from "@bitwarden/common/models/domain/organization"; import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { import { DialogService } from "@bitwarden/components";
DialogService,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/components";
import { VaultFilterService } from "../../../vault/app/vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "../../../vault/app/vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../../vault/app/vault/vault-filter/shared/models/vault-filter.model"; import { VaultFilter } from "../../../vault/app/vault/vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "../../../vault/app/vault/vault-filter/shared/models/vault-filter.type";
import { CollectionAdminService } from "../core";
import { EntityEventsComponent } from "../manage/entity-events.component"; import { EntityEventsComponent } from "../manage/entity-events.component";
import {
CollectionDialogResult,
openCollectionDialog,
} from "../shared/components/collection-dialog";
import { AddEditComponent } from "./add-edit.component"; import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component"; import { AttachmentsComponent } from "./attachments.component";
@ -86,8 +73,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private ngZone: NgZone, private ngZone: NgZone,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private cipherService: CipherService, private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService, private passwordRepromptService: PasswordRepromptService
private collectionAdminService: CollectionAdminService
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -108,7 +94,7 @@ export class VaultComponent implements OnInit, OnDestroy {
// verifies that the organization has been set // verifies that the organization has been set
combineLatest([this.route.queryParams, this.route.parent.params]) combineLatest([this.route.queryParams, this.route.parent.params])
.pipe( .pipe(
switchMap(async ([qParams, params]) => { switchMap(async ([qParams]) => {
const cipherId = getCipherIdFromParams(qParams); const cipherId = getCipherIdFromParams(qParams);
if (!cipherId) { if (!cipherId) {
return; return;
@ -171,68 +157,17 @@ export class VaultComponent implements OnInit, OnDestroy {
this.go(); this.go();
} }
async refreshItems() {
this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh();
await this.vaultItemsComponent.actionPromise;
this.vaultItemsComponent.actionPromise = null;
}
filterSearchText(searchText: string) { filterSearchText(searchText: string) {
this.vaultItemsComponent.searchText = searchText; this.vaultItemsComponent.searchText = searchText;
this.vaultItemsComponent.search(200); this.vaultItemsComponent.search(200);
} }
private showFreeOrgUpgradeDialog(): void {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canManageBilling
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
this.organization.maxCollections
),
type: SimpleDialogType.PRIMARY,
};
if (this.organization.canManageBilling) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts);
firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => {
if (!result) {
return;
}
if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) {
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
}
async addCollection() {
if (this.organization.planProductType === ProductType.Free) {
const collections = await this.collectionAdminService.getAll(this.organization.id);
if (collections.length === this.organization.maxCollections) {
this.showFreeOrgUpgradeDialog();
return;
}
}
const dialog = openCollectionDialog(this.dialogService, {
data: {
organizationId: this.organization?.id,
parentCollectionId: this.activeFilter.collectionId,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh();
await this.vaultItemsComponent.actionPromise;
this.vaultItemsComponent.actionPromise = null;
}
}
async editCipherAttachments(cipher: CipherView) { async editCipherAttachments(cipher: CipherView) {
if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) {
this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId });
@ -362,26 +297,6 @@ export class VaultComponent implements OnInit, OnDestroy {
}); });
} }
get breadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.activeFilter.selectedCollectionNode) {
return [];
}
const collections = [this.activeFilter.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections.map((c) => c).reverse();
}
protected applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.applyVaultFilter(filter);
}
private go(queryParams: any = null) { private go(queryParams: any = null) {
if (queryParams == null) { if (queryParams == null) {
queryParams = { queryParams = {

View File

@ -10,6 +10,7 @@ import { SharedModule } from "../../shared/shared.module";
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";
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 { VaultItemsComponent } from "./vault-items.component"; import { VaultItemsComponent } from "./vault-items.component";
import { VaultRoutingModule } from "./vault-routing.module"; import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component"; import { VaultComponent } from "./vault.component";
@ -26,7 +27,7 @@ import { VaultComponent } from "./vault.component";
PipesModule, PipesModule,
BreadcrumbsModule, BreadcrumbsModule,
], ],
declarations: [VaultComponent, VaultItemsComponent], declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
exports: [VaultComponent], exports: [VaultComponent],
}) })
export class VaultModule {} export class VaultModule {}

View File

@ -17,7 +17,7 @@
</button> </button>
<button <button
*ngIf="headerInfo.isSelectable" *ngIf="headerInfo.isSelectable"
appA11yTitle="{{ isOrganizationFilter ? 'vault' : ('filter' | i18n) }}: {{ appA11yTitle="{{ (isOrganizationFilter ? 'vault' : 'filter') | i18n }}: {{
headerNode.node.name | i18n headerNode.node.name | i18n
}}" }}"
class="filter-button" class="filter-button"

View File

@ -20,6 +20,48 @@ export class VaultFilter {
selectedFolderNode: TreeNode<FolderFilter>; selectedFolderNode: TreeNode<FolderFilter>;
selectedCollectionNode: TreeNode<CollectionFilter>; selectedCollectionNode: TreeNode<CollectionFilter>;
/**
* A list of collection filters that form a chain from the organization root to currently selected collection.
* To be used when rendering a breadcrumb UI for navigating the collection hierarchy.
* Begins from the organization root and excludes the currently selected collection.
*/
get collectionBreadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.isCollectionSelected) {
return [];
}
const collections = [this.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections.slice(1).reverse();
}
/**
* The vault is filtered by a specific collection
*/
get isCollectionSelected(): boolean {
return (
this.selectedCollectionNode != null &&
this.selectedCollectionNode.node.id !== "AllCollections"
);
}
/**
* The vault is filtered by the "Unassigned" collection
*/
get isUnassignedCollectionSelected(): boolean {
return this.selectedCollectionNode != null && this.selectedCollectionNode.node.id === null;
}
/**
* The vault is filtered by the users individual vault
*/
get isMyVaultSelected(): boolean {
return this.selectedOrganizationNode?.node.id === "MyVault";
}
get isFavorites(): boolean { get isFavorites(): boolean {
return this.selectedCipherTypeNode?.node.type === "favorites"; return this.selectedCipherTypeNode?.node.type === "favorites";
} }

View File

@ -0,0 +1,42 @@
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
<div>
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
<bit-breadcrumb
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
>
<!-- First node in the tree is the "Org Name Vault" item. The rest come from user input. -->
<ng-container *ngIf="first">
{{ activeOrganizationId | orgNameFromId: (organizations$ | async) }}
{{ "vault" | i18n | lowercase }}
</ng-container>
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
</bit-breadcrumb>
</bit-breadcrumbs>
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
<i
*ngIf="activeFilter.isCollectionSelected"
class="bwi bwi-collection"
aria-hidden="true"
></i>
<span>{{ title }}</span>
<small #actionSpinner [appApiAction]="actionPromise">
<ng-container *ngIf="$any(actionSpinner).loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
</small>
</h1>
</div>
<div *ngIf="!activeFilter.isDeleted" class="tw-shrink-0">
<button type="button" bitButton buttonType="primary" (click)="addCipher()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "newItem" | i18n }}
</button>
</div>
</div>

View File

@ -0,0 +1,85 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { VaultFilter } from "../vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "../vault-filter/shared/models/vault-filter.type";
@Component({
selector: "app-vault-header",
templateUrl: "./vault-header.component.html",
})
export class VaultHeaderComponent {
/**
* Promise that is used to determine the loading state of the header via the ApiAction directive.
* When the promise exists and is not resolved, the loading spinner will be shown.
*/
@Input() actionPromise: Promise<any>;
/**
* The filter being actively applied to the vault view
*/
@Input() activeFilter: VaultFilter;
/**
* Emits when the active filter has been modified by the header
*/
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
/**
* Emits an event when the new item button is clicked in the header
*/
@Output() onAddCipher = new EventEmitter<void>();
organizations$ = this.organizationService.organizations$;
constructor(private organizationService: OrganizationService, private i18nService: I18nService) {}
/**
* The id of the organization that is currently being filtered on.
* This can come from a collection filter or organization filter, if applied.
*/
get activeOrganizationId() {
if (this.activeFilter.selectedCollectionNode != null) {
return this.activeFilter.selectedCollectionNode.node.organizationId;
}
if (this.activeFilter.selectedOrganizationNode != null) {
return this.activeFilter.selectedOrganizationNode.node.id;
}
return undefined;
}
get title() {
if (this.activeFilter.isCollectionSelected) {
if (this.activeFilter.isUnassignedCollectionSelected) {
return this.i18nService.t("unassigned");
}
return this.activeFilter.selectedCollectionNode.node.name;
}
if (this.activeFilter.isMyVaultSelected) {
return this.i18nService.t("myVault");
}
if (this.activeFilter?.selectedOrganizationNode != null) {
return `${this.activeFilter.selectedOrganizationNode.node.name} ${this.i18nService
.t("vault")
.toLowerCase()}`;
}
return this.i18nService.t("allVaults");
}
applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.activeFilterChanged.emit(filter);
}
addCipher() {
this.onAddCipher.emit();
}
}

View File

@ -17,43 +17,12 @@
</div> </div>
</div> </div>
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }"> <div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
<bit-breadcrumbs *ngIf="breadcrumbs.length > 0"> <app-vault-header
<bit-breadcrumb [activeFilter]="activeFilter"
*ngFor="let collection of breadcrumbs; let first = first" (activeFilterChanged)="applyVaultFilter($event)"
[icon]="first ? undefined : 'bwi-collection'" [actionPromise]="vaultItemsComponent.actionPromise"
(click)="applyCollectionFilter(collection)" (onAddCipher)="addCipher()"
> ></app-vault-header>
<!-- First node in the tree contains a translation key. The rest come from user input. -->
<ng-container *ngIf="first">{{ collection.node.name | i18n }}</ng-container>
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
</bit-breadcrumb>
</bit-breadcrumbs>
<div class="tw-mb-4 tw-flex">
<h1>
{{ "vaultItems" | i18n }}
<small #actionSpinner [appApiAction]="vaultItemsComponent.actionPromise">
<ng-container *ngIf="$any(actionSpinner).loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
</small>
</h1>
<div class="ml-auto d-flex">
<button
type="button"
bitButton
buttonType="primary"
(click)="addCipher()"
*ngIf="!activeFilter.isDeleted"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "newItem" | i18n }}
</button>
</div>
</div>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle"> <app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</app-callout> </app-callout>

View File

@ -38,11 +38,7 @@ import { ShareComponent } from "./share.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "./vault-filter/shared/models/vault-filter.type";
import { VaultItemsComponent } from "./vault-items.component"; import { VaultItemsComponent } from "./vault-items.component";
const BroadcasterSubscriptionId = "VaultComponent"; const BroadcasterSubscriptionId = "VaultComponent";
@ -394,26 +390,6 @@ export class VaultComponent implements OnInit, OnDestroy {
return kdfType === KdfType.PBKDF2_SHA256 && kdfOptions.iterations < DEFAULT_PBKDF2_ITERATIONS; return kdfType === KdfType.PBKDF2_SHA256 && kdfOptions.iterations < DEFAULT_PBKDF2_ITERATIONS;
} }
get breadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.activeFilter.selectedCollectionNode) {
return [];
}
const collections = [this.activeFilter.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections.map((c) => c).reverse();
}
protected applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.applyVaultFilter(filter);
}
private go(queryParams: any = null) { private go(queryParams: any = null) {
if (queryParams == null) { if (queryParams == null) {
queryParams = { queryParams = {

View File

@ -4,12 +4,13 @@ import { BreadcrumbsModule } from "@bitwarden/components";
import { CollectionBadgeModule } from "../../../app/organizations/vault/collection-badge/collection-badge.module"; import { CollectionBadgeModule } from "../../../app/organizations/vault/collection-badge/collection-badge.module";
import { GroupBadgeModule } from "../../../app/organizations/vault/group-badge/group-badge.module"; import { GroupBadgeModule } from "../../../app/organizations/vault/group-badge/group-badge.module";
import { SharedModule, LooseComponentsModule } from "../../../app/shared"; import { LooseComponentsModule, SharedModule } from "../../../app/shared";
import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module";
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
import { PipesModule } from "./pipes/pipes.module"; import { PipesModule } from "./pipes/pipes.module";
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 { VaultItemsComponent } from "./vault-items.component"; import { VaultItemsComponent } from "./vault-items.component";
import { VaultRoutingModule } from "./vault-routing.module"; import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component"; import { VaultComponent } from "./vault.component";
@ -27,7 +28,7 @@ import { VaultComponent } from "./vault.component";
BulkDialogsModule, BulkDialogsModule,
BreadcrumbsModule, BreadcrumbsModule,
], ],
declarations: [VaultComponent, VaultItemsComponent], declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
exports: [VaultComponent], exports: [VaultComponent],
}) })
export class VaultModule {} export class VaultModule {}