[AC-974] [Technical Dependency] Refactor Vault Tables (#4967)

* [EC-974] feat: scaffold new vault-items component

* [EC-974] feat: add basic mocked data to story

* [EC-974] feat: add initial table version

* [EC-974] chore: split rows into separate components

* [EC-974] chore: rename item row to cipher row

* [EC-974] feat: create common vault item interface

* [EC-974] feat: use cdk virtual scrolling

* [EC-974] fix: tweak `itemSize`

* [EC-974] chore: move vault-items component to app/vault folder

* [EC-974] feat: initial support for extra column

* [EC-974] feat: start adding org badge

Having issues with modules import

* [EC-974] feat: add working owner column on collections row

* [EC-974] feat: add owner to ciphers

* [EC-974] fix: org name badge bugs when reused

* [EC-974] feat: fix and translate columns

* [EC-974] feat: allow collections to be non-editable

* [EC-974] feat: use data source

* [EC-974] fix: remove profile name from vault items

* [EC-974] feat: add events

* [EC-974] feat: add support for copy event

* [EC-974] feat: add support for collections column

* [EC-974] feat: add support for group badges

* [EC-974] chore: rename for consistency

* [EC-974] feat: change story to use template

* [EC-974] feat: add support for launching

* [EC-974] feat: add support for attachements

* [EC-974] feat: add stories for all use-cases

* [EC-974] feat: add support for cloning

* [EC-974] feat: add support for moving to organization

* [EC-974] feat: add support for editing cipher collections

* [EC-974] feat: add support for event logs

* [EC-974] feat: add support for trash/delete/restore

* [EC-974] feat: add support for editing collections

* [EC-974] feat: add support for access and delete collections

* [EC-974] feat: don't show menu if it's empty

* [EC-974] feat: initial buggy implementation of selection

* [EC-974] feat: implement bulk move

* [EC-974] feat: add support for bulk moving to org

* [EC-974] feat: add support for bulk restore

* [EC-974] feat: add support for bulk delete

* [EC-974] feat: add ability to disable the table

* [EC-974] feat: create new filter function based on routed model

* [EC-974] wip: start replacing vault items component

* [EC-974] feat: add support for fetching ciphers

* [EC-974] feat: hide trash by default

* [EC-974] feat: add support for the rest of the data

* [EC-974] feat: implement organization filtering using org badge

* [EC-974] feat: fix navigation to "my vault"

* [EC-974] feat: don't show bulk move options when filtering on org items

* [EC-974] feat: prepare for disabling table

* [EC-974] fix: add missing router link to collections

* [EC-974] feat: connect all outputs

* [EC-974] fix: list not properly refreshing after delete

* [EC-974] feat: limit selection to top 500 items

* [EC-974] feat: implement refresh tracker

* [EC-974] feat: use refresh tracker to disable vault items

* [EC-974] feat: add empty list message

* [AC-974] feat: add initial load with spinner and fix empty -> show list bug

* [EC-974] feat: replace action promise with simple loading boolean

* [EC-974] feat: refactor individual vault header

* [EC-974] feat: cache and make observables long lived

* [EC-974] feat: implement searching

* [EC-974] feat: add support for showing collections

* [EC-974] feat: add ciphers to org vault list

* [EC-974] feat: show group column

* [EC-974] feat: tweak settings for org vault

* [EC-974] feat: implement search using query params

* [EC-974] feat: add support for events that are common with individual vault

* [EC-974] feat: add support for all events

* [EC-974] feat: add support for empty list message and no permission message

* [EC-974] feat: always show table

* [EC-974] feat: fix layout issues due to incorrect row height

* [EC-974] feat: disable list if empty

* [EC-974] feat: improve sync handling

* [EC-974] feat: improve initial loading sequence

* [EC-974] feat: improve initial load sequence in org vault

* [EC-974] refactor: simplify and optimize data fetching

* [EC-974] feat: use observables from org service

* [EC-974] feat: refactor org vault header

* [EC-974] fix: data not refreshing properly

* [EC-974] fix: avoid collection double fetching

* [EC-974] chore: clean up refresh tracker

* [EC-974] chore: clean up old vault-items components

* [EC-974] chore: clean up old code in vault component

* [EC-974] fix: reduce rows in story

The story ends up too big for chromatic.

* [EC-974] docs: tweak and typo fixes of asyncToObservable docs comment

* [EC-974] fix: `attachements` typo

* [EC-974] chore: remove review question comment

* [EC-974] chore: remove unused `securityCode` if statement

* [EC-974] fix: use `takeUntill` for legacy dialogs

* [EC-974] fix: use CollectionDialogTabType instead of custom strings

* [EC-974] fix: copy implementation

* [EC-974] fix: use `useTotp` to check for premium features

* [EC-974] fix: use `tw-sr-only`

* [EC-974] chore: remove unecessary eslint disable

* [EC-974] fix: clarify vault item event naming

* [EC-974] fix: remove `new` from `app-new-vault-items`

* [EC-974] fix: collection row not disabled during loading

* [EC-974] chore: simplify router links without path changes

* [EC-974] feat: invert filter function to get rid of `cipherPassesFilter`

* [EC-974] fix: move `NestingDelimiter` to collection view

Nesting is currently only a presentational construct, and the concept does not exist in our domain.

* [EC-974] fix: org vault header not updating when switching org

* [EC-974] fix: table sizing jumping around

* [EC-974] fix: list not refreshing after restoring item

* [EC-974] fix: re-add missing unassigned collection

* [EC-974] fix don't show new item button in unassigned collection

* [EC-974] fix: navigations always leading to individual vault

* [EC-974] fix: remove checkbox when collections are not editable

* [EC-974] fix: null reference blocking collections from refreshing after delete

* [EC-974] fix: don't show checbox for collections that user does not have permissions to delete

* [EC-974] fix: navigate away from deleted folder

* [EC-974] chore: clean up un-used output

* [EC-974] fix: org badge changing color randomly

* [EC-974] fix: lint issues after merge

* [EC-974] fix: lower amount of ciphers in story

chromatic doesn't like large snapshots

* [EC-974] fix: "all collections" not taking `organizationId` filter into account

* [EC-974] fix: make sure unassigned appears in table too

* [EC-974] feat: add unassigned to storybook

* [EC-974] fix: forced row height not being applied properly

* [EC-974] fix: hopefully fix table jumping once and for all

* [EC-974] fix: attachemnts getting hidden

* [EC-974] feat: extract collection editable logic to parent component

* [EC-974] feat: separately track editable items

* [EC-974] feat: optimize permission checks

* [EC-974] fix: bulk menu hidden on chrome

:lolcry:

* [EC-974] fix: don't show groups column if org doesnt use groups

* [EC-974] feat: make entire row clickable

* [EC-974] fix: typo resulting in non-editable collections
This commit is contained in:
Andreas Coroiu 2023-04-13 20:48:29 +02:00 committed by GitHub
parent 5f26e58538
commit 0bc6add5c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 3011 additions and 1775 deletions

View File

@ -0,0 +1,147 @@
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<input
type="checkbox"
bitCheckbox
appStopProp
[disabled]="disabled"
[checked]="checked"
(change)="$event ? this.checkedToggled.next() : null"
/>
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</td>
<td
bitCell
[ngClass]="RowHeightClass"
class="tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap"
>
<div class="tw-inline-flex tw-w-full">
<button
bitLink
class="tw-overflow-hidden tw-text-ellipsis tw-text-start"
[disabled]="disabled"
[routerLink]="[]"
[queryParams]="{ itemId: cipher.id }"
queryParamsHandling="merge"
title="{{ 'editItem' | i18n }}"
type="button"
>
{{ cipher.name }}
</button>
<ng-container *ngIf="cipher.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-2"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "attachments" | i18n }}</span>
<ng-container *ngIf="showFixOldAttachments">
<i
class="bwi bwi-exclamation-triangle tw-ml-2 tw-text-warning"
appStopProp
title="{{ 'attachmentsNeedFix' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "attachmentsNeedFix" | i18n }}</span>
</ng-container>
</ng-container>
</div>
<br />
<span class="tw-text-sm tw-text-muted" appStopProp>{{ cipher.subTitle }}</span>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner">
<app-org-badge
[disabled]="disabled"
[organizationId]="cipher.organizationId"
[organizationName]="cipher.organizationId | orgNameFromId : organizations"
appStopProp
>
</app-org-badge>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showCollections">
<app-collection-badge
*ngIf="cipher.collectionIds"
[collectionIds]="cipher.collectionIds"
[collections]="collections"
></app-collection-badge>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showGroups"></td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
[disabled]="disabled"
[bitMenuTriggerFor]="cipherOptions"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="cipher.type === CipherType.Login && !cipher.isDeleted">
<button bitMenuItem type="button" (click)="copy('username')">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="cipher.viewPassword">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }}
</button>
<a
bitMenuItem
*ngIf="cipher.login.canLaunch"
type="button"
[href]="cipher.login.launchUri"
target="_blank"
>
<i class="bwi bwi-fw bwi-share-square" aria-hidden="true"></i>
{{ "launch" | i18n }}
</a>
</ng-container>
<button bitMenuItem type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
<button bitMenuItem *ngIf="cloneable && !cipher.isDeleted" type="button" (click)="clone()">
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
<button
bitMenuItem
*ngIf="!cipher.organizationId && !cipher.isDeleted"
type="button"
(click)="moveToOrganization()"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveToOrganization" | i18n }}
</button>
<button
bitMenuItem
*ngIf="cipher.organizationId && !cipher.isDeleted"
type="button"
(click)="editCollections()"
>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collections" | i18n }}
</button>
<button bitMenuItem *ngIf="cipher.organizationId && useEvents" type="button" (click)="events()">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button bitMenuItem (click)="restore()" type="button" *ngIf="cipher.isDeleted">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button bitMenuItem (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>
</td>

View File

@ -0,0 +1,94 @@
import { Component, EventEmitter, HostBinding, HostListener, Input, Output } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { VaultItemEvent } from "./vault-item-event";
import { RowHeightClass } from "./vault-items.component";
@Component({
selector: "tr[appVaultCipherRow]",
templateUrl: "vault-cipher-row.component.html",
})
export class VaultCipherRowComponent {
protected RowHeightClass = RowHeightClass;
@Input() disabled: boolean;
@Input() cipher: CipherView;
@Input() showOwner: boolean;
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@Input() showPremiumFeatures: boolean;
@Input() useEvents: boolean;
@Input() cloneable: boolean;
@Input() organizations: Organization[];
@Input() collections: CollectionView[];
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>();
protected CipherType = CipherType;
constructor(private router: Router, private activatedRoute: ActivatedRoute) {}
@HostBinding("class")
get classes() {
return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]);
}
protected get showTotpCopyButton() {
return (
(this.cipher.login?.hasTotp ?? false) &&
(this.cipher.organizationUseTotp || this.showPremiumFeatures)
);
}
protected get showFixOldAttachments() {
return this.cipher.hasOldAttachments && this.cipher.organizationId == null;
}
@HostListener("click")
protected click() {
this.router.navigate([], {
queryParams: { cipherId: this.cipher.id },
queryParamsHandling: "merge",
});
}
protected copy(field: "username" | "password" | "totp") {
this.onEvent.emit({ type: "copyField", item: this.cipher, field });
}
protected clone() {
this.onEvent.emit({ type: "clone", item: this.cipher });
}
protected moveToOrganization() {
this.onEvent.emit({ type: "moveToOrganization", items: [this.cipher] });
}
protected editCollections() {
this.onEvent.emit({ type: "viewCollections", item: this.cipher });
}
protected events() {
this.onEvent.emit({ type: "viewEvents", item: this.cipher });
}
protected restore() {
this.onEvent.emit({ type: "restore", items: [this.cipher] });
}
protected deleteCipher() {
this.onEvent.emit({ type: "delete", items: [{ cipher: this.cipher }] });
}
protected attachments() {
this.onEvent.emit({ type: "viewAttachments", item: this.cipher });
}
}

View File

@ -0,0 +1,75 @@
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<input
type="checkbox"
bitCheckbox
appStopProp
*ngIf="canDeleteCollection"
[disabled]="disabled"
[checked]="checked"
(change)="$event ? this.checkedToggled.next() : null"
/>
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<div class="icon" aria-hidden="true">
<i class="bwi bwi-fw bwi-lg bwi-collection"></i>
</div>
</td>
<td bitCell [ngClass]="RowHeightClass">
<button
bitLink
[disabled]="disabled"
type="button"
class="tw-w-full tw-overflow-hidden tw-text-ellipsis tw-text-start"
linkType="secondary"
[routerLink]="[]"
[queryParams]="{ collectionId: collection.id }"
queryParamsHandling="merge"
>
{{ collection.name }}
</button>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner">
<app-org-badge
[disabled]="disabled"
[organizationId]="collection.organizationId"
[organizationName]="collection.organizationId | orgNameFromId : organizations"
appStopProp
>
</app-org-badge>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showCollections"></td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showGroups">
<app-group-badge
*ngIf="collectionGroups"
[selectedGroups]="collectionGroups"
[allGroups]="groups"
></app-group-badge>
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
*ngIf="canEditCollection || canDeleteCollection"
[disabled]="disabled"
[bitMenuTriggerFor]="collectionOptions"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #collectionOptions>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="edit()">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="access()">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button *ngIf="canDeleteCollection" type="button" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>

View File

@ -0,0 +1,72 @@
import { Component, EventEmitter, HostBinding, HostListener, Input, Output } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { CollectionAdminView, GroupView } from "../../../admin-console/organizations/core";
import { VaultItemEvent } from "./vault-item-event";
import { RowHeightClass } from "./vault-items.component";
@Component({
selector: "tr[appVaultCollectionRow]",
templateUrl: "vault-collection-row.component.html",
})
export class VaultCollectionRowComponent {
protected RowHeightClass = RowHeightClass;
@Input() disabled: boolean;
@Input() collection: CollectionView;
@Input() showOwner: boolean;
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@Input() canEditCollection: boolean;
@Input() canDeleteCollection: boolean;
@Input() organizations: Organization[];
@Input() groups: GroupView[];
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>();
constructor(private router: Router, private activatedRoute: ActivatedRoute) {}
@HostBinding("class")
get classes() {
return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]);
}
get collectionGroups() {
if (!(this.collection instanceof CollectionAdminView)) {
return [];
}
return this.collection.groups;
}
get organization() {
return this.organizations.find((o) => o.id === this.collection.organizationId);
}
@HostListener("click")
protected click() {
this.router.navigate([], {
queryParams: { collectionId: this.collection.id },
queryParamsHandling: "merge",
});
}
protected edit() {
this.onEvent.next({ type: "edit", item: this.collection });
}
protected access() {
this.onEvent.next({ type: "viewAccess", item: this.collection });
}
protected deleteCollection() {
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
}
}

View File

@ -0,0 +1,17 @@
import { CollectionView } from "@bitwarden/common/src/admin-console/models/view/collection.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { VaultItem } from "./vault-item";
export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView }
| { type: "viewAccess"; item: CollectionView }
| { type: "viewEvents"; item: CipherView }
| { type: "edit"; item: CollectionView }
| { type: "clone"; item: CipherView }
| { type: "restore"; items: CipherView[] }
| { type: "delete"; items: VaultItem[] }
| { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: CipherView[] }
| { type: "moveToOrganization"; items: CipherView[] };

View File

@ -0,0 +1,7 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
export interface VaultItem {
collection?: CollectionView;
cipher?: CipherView;
}

View File

@ -0,0 +1,105 @@
<cdk-virtual-scroll-viewport [itemSize]="RowHeight" scrollWindow class="tw-pb-8">
<bit-table [dataSource]="dataSource" layout="fixed">
<ng-container header>
<tr>
<th bitCell class="tw-w-24 tw-whitespace-nowrap" colspan="2">
<input
class="tw-mr-2"
type="checkbox"
bitCheckbox
id="checkAll"
[disabled]="disabled || isEmpty"
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="checkAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell [class]="showExtraColumn ? 'tw-w-2/5' : 'tw-w-full'">{{ "name" | i18n }}</th>
<th bitCell class="tw-w-3/5" *ngIf="showOwner">{{ "owner" | i18n }}</th>
<th bitCell class="tw-w-3/5" *ngIf="showCollections">{{ "collections" | i18n }}</th>
<th bitCell class="tw-w-3/5" *ngIf="showGroups">{{ "groups" | i18n }}</th>
<th bitCell class="tw-w-12 tw-text-right">
<button
[disabled]="disabled || isEmpty"
[bitMenuTriggerFor]="headerMenu"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button *ngIf="showBulkMove" type="button" bitMenuItem (click)="bulkMoveToFolder()">
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }}
</button>
<button
*ngIf="showBulkMove"
type="button"
bitMenuItem
(click)="bulkMoveToOrganization()"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveSelectedToOrg" | i18n }}
</button>
<button *ngIf="showBulkTrashOptions" type="button" bitMenuItem (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restoreSelected" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkDelete()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (showBulkTrashOptions ? "permanentlyDeleteSelected" : "deleteSelected") | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<ng-container *cdkVirtualFor="let item of rows$">
<tr
*ngIf="item.collection"
bitRow
appVaultCollectionRow
alignContent="middle"
[disabled]="disabled"
[collection]="item.collection"
[showOwner]="showOwner"
[showCollections]="showCollections"
[showGroups]="showGroups"
[organizations]="allOrganizations"
[groups]="allGroups"
[canDeleteCollection]="canDeleteCollection(item.collection)"
[canEditCollection]="canEditCollection(item.collection)"
[checked]="selection.isSelected(item)"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
></tr>
<tr
*ngIf="item.cipher"
bitRow
appVaultCipherRow
alignContent="middle"
[disabled]="disabled"
[cipher]="item.cipher"
[showOwner]="showOwner"
[showCollections]="showCollections"
[showGroups]="showGroups"
[showPremiumFeatures]="showPremiumFeatures"
[useEvents]="useEvents"
[cloneable]="
(item.cipher.organizationId && cloneableOrganizationCiphers) ||
item.cipher.organizationId == null
"
[organizations]="allOrganizations"
[collections]="allCollections"
[checked]="selection.isSelected(item)"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
></tr>
</ng-container>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>

View File

@ -0,0 +1,179 @@
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TableDataSource } from "@bitwarden/components";
import { CollectionAdminView, GroupView } from "../../../admin-console/organizations/core";
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event";
// Fixed manual row height required due to how cdk-virtual-scroll works
export const RowHeight = 65;
export const RowHeightClass = `tw-h-[65px]`;
const MaxSelectionCount = 500;
@Component({
selector: "app-vault-items",
templateUrl: "vault-items.component.html",
// TODO: Improve change detection, see: https://bitwarden.atlassian.net/browse/TDL-220
// changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultItemsComponent {
protected RowHeight = RowHeight;
@Input() disabled: boolean;
@Input() showOwner: boolean;
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@Input() useEvents: boolean;
@Input() editableCollections: boolean;
@Input() cloneableOrganizationCiphers: boolean;
@Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean;
@Input() showBulkTrashOptions: boolean;
@Input() allOrganizations: Organization[] = [];
@Input() allCollections: CollectionView[] = [];
@Input() allGroups: GroupView[] = [];
private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] {
return this._ciphers;
}
set ciphers(value: CipherView[] | undefined) {
this._ciphers = value ?? [];
this.refreshItems();
}
private _collections?: CollectionView[] = [];
@Input() get collections(): CollectionView[] {
return this._collections;
}
set collections(value: CollectionView[] | undefined) {
this._collections = value ?? [];
this.refreshItems();
}
@Output() onEvent = new EventEmitter<VaultItemEvent>();
protected editableItems: VaultItem[] = [];
protected dataSource = new TableDataSource<VaultItem>();
protected selection = new SelectionModel<VaultItem>(true, [], true);
get showExtraColumn() {
return this.showCollections || this.showGroups || this.showOwner;
}
get isAllSelected() {
return this.editableItems
.slice(0, MaxSelectionCount)
.every((item) => this.selection.isSelected(item));
}
get isEmpty() {
return this.dataSource.data.length === 0;
}
protected canEditCollection(collection: CollectionView): boolean {
// We currently don't support editing collections from individual vault
if (!(collection instanceof CollectionAdminView)) {
return false;
}
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (!this.editableCollections || collection.id === Unassigned) {
return false;
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
// Otherwise, check if we can edit the specified collection
return (
organization?.canEditAnyCollection ||
(organization?.canEditAssignedCollections && collection.assigned)
);
}
protected canDeleteCollection(collection: CollectionView): boolean {
// We currently don't support editing collections from individual vault
if (!(collection instanceof CollectionAdminView)) {
return false;
}
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (!this.editableCollections || collection.id === Unassigned) {
return false;
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
// Otherwise, check if we can delete the specified collection
return (
organization?.canDeleteAnyCollection ||
(organization?.canDeleteAssignedCollections && collection.assigned)
);
}
protected toggleAll() {
this.isAllSelected
? this.selection.clear()
: this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
}
protected event(event: VaultItemEvent) {
this.onEvent.emit(event);
}
protected bulkMoveToFolder() {
this.event({
type: "moveToFolder",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkMoveToOrganization() {
this.event({
type: "moveToOrganization",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkRestore() {
this.event({
type: "restore",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkDelete() {
this.event({
type: "delete",
items: this.selection.selected,
});
}
private refreshItems() {
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
const items: VaultItem[] = [].concat(collections).concat(ciphers);
this.selection.clear();
this.editableItems = items.filter(
(item) =>
item.cipher !== undefined ||
(item.collection !== undefined && this.canDeleteCollection(item.collection))
);
this.dataSource.data = items;
}
}

View File

@ -0,0 +1,33 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { TableModule } from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module";
import { OrganizationBadgeModule } from "../../individual-vault/organization-badge/organization-badge.module";
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
import { CollectionBadgeModule } from "../../org-vault/collection-badge/collection-badge.module";
import { GroupBadgeModule } from "../../org-vault/group-badge/group-badge.module";
import { VaultCipherRowComponent } from "./vault-cipher-row.component";
import { VaultCollectionRowComponent } from "./vault-collection-row.component";
import { VaultItemsComponent } from "./vault-items.component";
@NgModule({
imports: [
CommonModule,
RouterModule,
ScrollingModule,
SharedModule,
TableModule,
OrganizationBadgeModule,
CollectionBadgeModule,
GroupBadgeModule,
PipesModule,
],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
exports: [VaultItemsComponent],
})
export class VaultItemsModule {}

View File

@ -0,0 +1,316 @@
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { BehaviorSubject } from "rxjs";
import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import {
CollectionAccessSelectionView,
CollectionAdminView,
GroupView,
} from "../../../admin-console/organizations/core";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { VaultItemsComponent } from "./vault-items.component";
import { VaultItemsModule } from "./vault-items.module";
@Component({
template: "",
})
class EmptyComponent {}
const organizations = [...new Array(3).keys()].map(createOrganization);
const groups = [...Array(3).keys()].map(createGroupView);
const collections = [...Array(5).keys()].map(createCollectionView);
const ciphers = [...Array(50).keys()].map((i) => createCipherView(i));
const deletedCiphers = [...Array(15).keys()].map((i) => createCipherView(i, true));
const organizationOnlyCiphers = ciphers.filter((c) => c.organizationId != undefined);
const deletedOrganizationOnlyCiphers = deletedCiphers.filter((c) => c.organizationId != undefined);
export default {
title: "Web/Vault/Items",
component: VaultItemsComponent,
decorators: [
moduleMetadata({
imports: [
VaultItemsModule,
PreloadedEnglishI18nModule,
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true }),
],
providers: [
{
provide: EnvironmentService,
useValue: {
getIconsUrl() {
return "";
},
} as Partial<EnvironmentService>,
},
{
provide: StateService,
useValue: {
activeAccount$: new BehaviorSubject("1").asObservable(),
accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(),
async getDisableFavicon() {
return false;
},
} as Partial<StateService>,
},
{
provide: AvatarUpdateService,
useValue: {
async loadColorFromState() {
return "#FF0000";
},
} as Partial<AvatarUpdateService>,
},
{
provide: TokenService,
useValue: {
async getUserId() {
return "user-id";
},
async getName() {
return "name";
},
async getEmail() {
return "email";
},
} as Partial<TokenService>,
},
],
}),
],
args: {
disabled: false,
allCollections: collections,
allGroups: groups,
allOrganizations: organizations,
},
argTypes: { onEvent: { action: "onEvent" } },
} as Meta;
const Template: Story<VaultItemsComponent> = (args: VaultItemsComponent) => ({
props: args,
});
export const Individual = Template.bind({});
Individual.args = {
ciphers,
collections: [],
showOwner: true,
showCollections: false,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: true,
showBulkTrashOptions: false,
useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false,
};
export const IndividualDisabled = Template.bind({});
IndividualDisabled.args = {
ciphers,
collections: [],
disabled: true,
showOwner: true,
showCollections: false,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: true,
showBulkTrashOptions: false,
useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false,
};
export const IndividualTrash = Template.bind({});
IndividualTrash.args = {
ciphers: deletedCiphers,
collections: [],
showOwner: true,
showCollections: false,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: true,
useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false,
};
export const IndividualTopLevelCollection = Template.bind({});
IndividualTopLevelCollection.args = {
ciphers: [],
collections,
showOwner: true,
showCollections: false,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: false,
useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false,
};
export const IndividualSecondLevelCollection = Template.bind({});
IndividualSecondLevelCollection.args = {
ciphers,
collections,
showOwner: true,
showCollections: false,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: true,
showBulkTrashOptions: false,
useEvents: false,
editableCollections: false,
cloneableOrganizationCiphers: false,
};
export const OrganizationVault = Template.bind({});
OrganizationVault.args = {
ciphers: organizationOnlyCiphers,
collections: [],
showOwner: false,
showCollections: true,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: false,
useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true,
};
export const OrganizationTrash = Template.bind({});
OrganizationTrash.args = {
ciphers: deletedOrganizationOnlyCiphers,
collections: [],
showOwner: false,
showCollections: true,
showGroups: false,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: true,
useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true,
};
const unassignedCollection = new CollectionAdminView();
unassignedCollection.id = Unassigned;
unassignedCollection.name = "Unassigned";
export const OrganizationTopLevelCollection = Template.bind({});
OrganizationTopLevelCollection.args = {
ciphers: [],
collections: collections.concat(unassignedCollection),
showOwner: false,
showCollections: false,
showGroups: true,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: false,
useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true,
};
export const OrganizationSecondLevelCollection = Template.bind({});
OrganizationSecondLevelCollection.args = {
ciphers: organizationOnlyCiphers,
collections,
showOwner: false,
showCollections: false,
showGroups: true,
showPremiumFeatures: true,
showBulkMove: false,
showBulkTrashOptions: false,
useEvents: true,
editableCollections: true,
cloneableOrganizationCiphers: true,
};
function createCipherView(i: number, deleted = false): CipherView {
const organization = organizations[i % (organizations.length + 1)];
const collection = collections[i % (collections.length + 1)];
const view = new CipherView();
view.id = `cipher-${i}`;
view.name = `Vault item ${i}`;
view.type = CipherType.Login;
view.organizationId = organization?.id;
view.deletedDate = deleted ? new Date() : undefined;
view.login = new LoginView();
view.login.username = i % 10 === 0 ? undefined : `username-${i}`;
view.login.totp = i % 2 === 0 ? "I65VU7K5ZQL7WB4E" : undefined;
view.login.uris = [new LoginUriView()];
view.login.uris[0].uri = "https://bitwarden.com";
view.collectionIds = collection ? [collection.id] : [];
if (i === 0) {
// Old attachment
const attachement = new AttachmentView();
view.organizationId = null;
view.collectionIds = [];
view.attachments = [attachement];
} else if (i % 5 === 0) {
const attachement = new AttachmentView();
attachement.key = new SymmetricCryptoKey(new ArrayBuffer(32));
view.attachments = [attachement];
}
return view;
}
function createCollectionView(i: number): CollectionAdminView {
const organization = organizations[i % (organizations.length + 1)];
const group = groups[i % (groups.length + 1)];
const view = new CollectionAdminView();
view.id = `collection-${i}`;
view.name = `Collection ${i}`;
view.organizationId = organization?.id;
if (group !== undefined) {
view.groups = [
new CollectionAccessSelectionView({
id: group.id,
hidePasswords: false,
readOnly: false,
}),
];
}
return view;
}
function createGroupView(i: number): GroupView {
const organization = organizations[i % organizations.length];
const view = new GroupView();
view.id = `group-${i}`;
view.name = `Group ${i}`;
view.organizationId = organization.id;
return view;
}
function createOrganization(i: number): Organization {
const organization = new Organization();
organization.id = `organization-${i}`;
organization.name = `Organization ${i}`;
organization.type = OrganizationUserType.Owner;
return organization;
}

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { SharedModule } from "../../../shared/shared.module";
import { OrganizationNameBadgeComponent } from "./organization-name-badge.component";

View File

@ -1,11 +1,13 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<button
bitBadge
type="button"
[disabled]="disabled"
[style.color]="textColor"
[style.background-color]="color"
appA11yTitle="{{ organizationName }}"
(click)="emitOnOrganizationClicked()"
routerLink
[queryParams]="{ organizationId: organizationIdLink }"
queryParamsHandling="merge"
>
{{ organizationName | ellipsis : 13 }}
{{ name | ellipsis : 13 }}
</button>

View File

@ -1,20 +1,23 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, Input, OnChanges } from "@angular/core";
import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { Utils } from "@bitwarden/common/misc/utils";
import { Unassigned } from "../vault-filter/shared/models/routed-vault-filter.model";
@Component({
selector: "app-org-badge",
templateUrl: "organization-name-badge.component.html",
})
export class OrganizationNameBadgeComponent implements OnInit {
export class OrganizationNameBadgeComponent implements OnChanges {
@Input() organizationId?: string;
@Input() organizationName: string;
@Input() profileName: string;
@Output() onOrganizationClicked = new EventEmitter<string>();
@Input() disabled: boolean;
// Need a separate variable or we get weird behavior when used as part of cdk virtual scrolling
name: string;
color: string;
textColor: string;
isMe: boolean;
@ -25,12 +28,13 @@ export class OrganizationNameBadgeComponent implements OnInit {
private tokenService: TokenService
) {}
async ngOnInit(): Promise<void> {
if (this.organizationName == null || this.organizationName === "") {
this.organizationName = this.i18nService.t("me");
this.isMe = true;
}
// ngOnChanges is required since this component might be reused as part of
// cdk virtual scrolling
async ngOnChanges() {
this.isMe = this.organizationName == null || this.organizationName === "";
if (this.isMe) {
this.name = this.i18nService.t("me");
this.color = await this.avatarService.loadColorFromState();
if (this.color == null) {
const userId = await this.tokenService.getUserId();
@ -43,12 +47,13 @@ export class OrganizationNameBadgeComponent implements OnInit {
}
}
} else {
this.name = this.organizationName;
this.color = Utils.stringToColor(this.organizationName.toUpperCase());
}
this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important";
}
emitOnOrganizationClicked() {
this.onOrganizationClicked.emit();
get organizationIdLink() {
return this.organizationId ?? Unassigned;
}
}

View File

@ -8,7 +8,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
})
export class GetOrgNameFromIdPipe implements PipeTransform {
transform(value: string, organizations: Organization[]) {
const orgName = organizations.find((o) => o.id === value)?.name;
const orgName = organizations?.find((o) => o.id === value)?.name;
return orgName;
}
}

View File

@ -20,7 +20,7 @@ export abstract class VaultFilterService {
folderTree$: Observable<TreeNode<FolderFilter>>;
collectionTree$: Observable<TreeNode<CollectionFilter>>;
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
reloadCollections: () => Promise<void>;
reloadCollections: (collections: CollectionView[]) => void;
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
expandOrgFilter: () => Promise<void>;

View File

@ -151,7 +151,12 @@ function createLegacyFilterForEndUser(
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId);
}
if (filter.organizationId !== undefined) {
if (filter.organizationId !== undefined && filter.organizationId === Unassigned) {
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
organizationTree,
"MyVault"
);
} else if (filter.organizationId !== undefined && filter.organizationId !== Unassigned) {
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
organizationTree,
filter.organizationId

View File

@ -3,7 +3,6 @@ import { firstValueFrom, ReplaySubject, take } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -23,7 +22,6 @@ describe("vault filter service", () => {
let organizationService: MockProxy<OrganizationService>;
let folderService: MockProxy<FolderService>;
let cipherService: MockProxy<CipherService>;
let collectionService: MockProxy<CollectionService>;
let policyService: MockProxy<PolicyService>;
let i18nService: MockProxy<I18nService>;
let organizations: ReplaySubject<Organization[]>;
@ -34,7 +32,6 @@ describe("vault filter service", () => {
organizationService = mock<OrganizationService>();
folderService = mock<FolderService>();
cipherService = mock<CipherService>();
collectionService = mock<CollectionService>();
policyService = mock<PolicyService>();
i18nService = mock<I18nService>();
i18nService.collator = new Intl.Collator("en-US");
@ -50,7 +47,6 @@ describe("vault filter service", () => {
organizationService,
folderService,
cipherService,
collectionService,
policyService,
i18nService
);
@ -177,8 +173,7 @@ describe("vault filter service", () => {
createCollectionView("1", "collection 1", "org test id"),
createCollectionView("2", "collection 2", "non matching org id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
vaultFilterService.reloadCollections(storedCollections);
await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([
createCollectionView("1", "collection 1", "org test id"),
@ -193,8 +188,7 @@ describe("vault filter service", () => {
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
vaultFilterService.reloadCollections(storedCollections);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
@ -207,8 +201,7 @@ describe("vault filter service", () => {
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
vaultFilterService.reloadCollections(storedCollections);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
@ -224,8 +217,7 @@ describe("vault filter service", () => {
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
vaultFilterService.reloadCollections(storedCollections);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
@ -243,8 +235,7 @@ describe("vault filter service", () => {
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
vaultFilterService.reloadCollections(storedCollections);
const result = await firstValueFrom(vaultFilterService.collectionTree$);

View File

@ -1,6 +1,7 @@
import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
firstValueFrom,
map,
@ -12,7 +13,6 @@ import {
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import {
isNotProviderUser,
OrganizationService,
@ -67,8 +67,10 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
// TODO: Remove once collections is refactored with observables
// replace with collection service observable
private collectionViews$ = new ReplaySubject<CollectionView[]>(1);
filteredCollections$: Observable<CollectionView[]> = this.collectionViews$.pipe(
combineLatestWith(this._organizationFilter),
filteredCollections$: Observable<CollectionView[]> = combineLatest([
this.collectionViews$,
this._organizationFilter,
]).pipe(
switchMap(([collections, org]) => {
return this.filterCollections(collections, org);
})
@ -84,14 +86,12 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected organizationService: OrganizationService,
protected folderService: FolderService,
protected cipherService: CipherService,
protected collectionService: CollectionService,
protected policyService: PolicyService,
protected i18nService: I18nService
) {}
// TODO: Remove once collections is refactored with observables
async reloadCollections() {
this.collectionViews$.next(await this.collectionService.getAllDecrypted());
async reloadCollections(collections: CollectionView[]) {
this.collectionViews$.next(collections);
}
async getCollectionNodeFromTree(id: string) {

View File

@ -0,0 +1,227 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { createFilterFunction } from "./filter-function";
import { Unassigned, All } from "./routed-vault-filter.model";
describe("createFilter", () => {
describe("given a generic cipher", () => {
it("should return true when no filter is applied", () => {
const cipher = createCipher();
const filterFunction = createFilterFunction({});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given a favorite cipher", () => {
const cipher = createCipher({ favorite: true });
it("should return true when filtering for favorites", () => {
const filterFunction = createFilterFunction({ type: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for trash", () => {
const filterFunction = createFilterFunction({ type: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a deleted cipher", () => {
const cipher = createCipher({ deletedDate: new Date() });
it("should return true when filtering for trash", () => {
const filterFunction = createFilterFunction({ type: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for favorites", () => {
const filterFunction = createFilterFunction({ type: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when type is not specified in filter", () => {
const filterFunction = createFilterFunction({});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher with type", () => {
it("should return true when filter matches cipher type", () => {
const cipher = createCipher({ type: CipherType.Identity });
const filterFunction = createFilterFunction({ type: "identity" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match cipher type", () => {
const cipher = createCipher({ type: CipherType.Card });
const filterFunction = createFilterFunction({ type: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher with folder id", () => {
it("should return true when filter matches folder id", () => {
const cipher = createCipher({ folderId: "folderId" });
const filterFunction = createFilterFunction({ folderId: "folderId" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match folder id", () => {
const cipher = createCipher({ folderId: "folderId" });
const filterFunction = createFilterFunction({ folderId: "differentFolderId" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher without folder", () => {
const cipher = createCipher({ folderId: null });
it("should return true when filtering on unassigned folder", () => {
const filterFunction = createFilterFunction({ folderId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given an organizational cipher (with organization and collections)", () => {
const cipher = createCipher({
organizationId: "organizationId",
collectionIds: ["collectionId", "anotherId"],
});
it("should return true when filter matches collection id", () => {
const filterFunction = createFilterFunction({
collectionId: "collectionId",
organizationId: "organizationId",
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match collection id", () => {
const filterFunction = createFilterFunction({
collectionId: "nonMatchingCollectionId",
organizationId: "organizationId",
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filter does not match organization id", () => {
const filterFunction = createFilterFunction({
organizationId: "nonMatchingOrganizationId",
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering for my vault only", () => {
const filterFunction = createFilterFunction({ organizationId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering by All Collections", () => {
const filterFunction = createFilterFunction({ collectionId: All });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given an unassigned organizational cipher (with organization, without collection)", () => {
const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] });
it("should return true when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({ collectionId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return true when filter matches organization id", () => {
const filterFunction = createFilterFunction({ organizationId: "organizationId" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given an individual cipher (without organization or collection)", () => {
const cipher = createCipher({ organizationId: null, collectionIds: [] });
it("should return false when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({ collectionId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return true when filtering for my vault only", () => {
const cipher = createCipher({ organizationId: null });
const filterFunction = createFilterFunction({ organizationId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
});
function createCipher(options: Partial<CipherView> = {}) {
const cipher = new CipherView();
cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate;
cipher.type = options.type;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;
return cipher;
}

View File

@ -0,0 +1,81 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { All, RoutedVaultFilterModel, Unassigned } from "./routed-vault-filter.model";
export type FilterFunction = (cipher: CipherView) => boolean;
export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction {
return (cipher) => {
if (filter.type === "favorites" && !cipher.favorite) {
return false;
}
if (filter.type === "card" && cipher.type !== CipherType.Card) {
return false;
}
if (filter.type === "identity" && cipher.type !== CipherType.Identity) {
return false;
}
if (filter.type === "login" && cipher.type !== CipherType.Login) {
return false;
}
if (filter.type === "note" && cipher.type !== CipherType.SecureNote) {
return false;
}
if (filter.type === "trash" && !cipher.isDeleted) {
return false;
}
// Hide trash unless explicitly selected
if (filter.type !== "trash" && cipher.isDeleted) {
return false;
}
// No folder
if (filter.folderId === Unassigned && cipher.folderId !== null) {
return false;
}
// Folder
if (
filter.folderId !== undefined &&
filter.folderId !== All &&
filter.folderId !== Unassigned &&
cipher.folderId !== filter.folderId
) {
return false;
}
// All collections (top level)
if (filter.collectionId === All) {
return false;
}
// Unassigned
if (
filter.collectionId === Unassigned &&
(cipher.organizationId == null ||
(cipher.collectionIds != null && cipher.collectionIds.length > 0))
) {
return false;
}
// Collection
if (
filter.collectionId !== undefined &&
filter.collectionId !== All &&
filter.collectionId !== Unassigned &&
(cipher.collectionIds == null || !cipher.collectionIds.includes(filter.collectionId))
) {
return false;
}
// My Vault
if (filter.organizationId === Unassigned && cipher.organizationId != null) {
return false;
}
// Organization
else if (
filter.organizationId !== undefined &&
filter.organizationId !== Unassigned &&
cipher.organizationId !== filter.organizationId
) {
return false;
}
return true;
};
}

View File

@ -53,7 +53,7 @@ export class RoutedVaultFilterBridge implements VaultFilter {
set selectedOrganizationNode(value: TreeNode<OrganizationFilter>) {
this.bridgeService.navigate({
...this.routedFilter,
organizationId: value.node.id,
organizationId: value?.node.id === "MyVault" ? Unassigned : value?.node.id,
folderId: undefined,
collectionId: undefined,
});

View File

@ -1,40 +1,45 @@
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
<div>
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
<bit-breadcrumbs *ngIf="showBreadcrumbs">
<bit-breadcrumb
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
*ngIf="activeOrganizationId"
[route]="[]"
[queryParams]="{ organizationId: activeOrganizationId, collectionId: All }"
queryParamsHandling="merge"
>
<!-- 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>
{{ activeOrganizationId | orgNameFromId : organizations }} {{ "vault" | i18n | lowercase }}
</bit-breadcrumb>
<ng-container>
<bit-breadcrumb
*ngFor="let collection of collections"
icon="bwi-collection"
[route]="[]"
[queryParams]="{ collectionId: collection.id }"
queryParamsHandling="merge"
>
{{ collection.name }}
</bit-breadcrumb>
</ng-container>
</bit-breadcrumbs>
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
<i
*ngIf="activeFilter.isCollectionSelected"
*ngIf="filter.collectionId && filter.collectionId !== All"
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 *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</small>
</h1>
</div>
<div *ngIf="!activeFilter.isDeleted" class="tw-shrink-0">
<div *ngIf="filter.type !== 'trash'" 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 }}

View File

@ -1,85 +1,118 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
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";
import {
All,
RoutedVaultFilterModel,
Unassigned,
} from "../vault-filter/shared/models/routed-vault-filter.model";
@Component({
selector: "app-vault-header",
templateUrl: "./vault-header.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
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>;
protected Unassigned = Unassigned;
protected All = All;
/**
* The filter being actively applied to the vault view
* Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true
*/
@Input() activeFilter: VaultFilter;
@Input() loading: boolean;
/** Current active fitler */
@Input() filter: RoutedVaultFilterModel;
/**
* Emits when the active filter has been modified by the header
* All organizations that can be shown
*/
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
@Input() organizations: Organization[] = [];
/**
* Currently selected collection
*/
@Input() collection?: TreeNode<CollectionView>;
/**
* 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) {}
constructor(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;
protected get activeOrganizationId() {
if (this.collection != undefined) {
return this.collection.node.organizationId;
}
if (this.activeFilter.selectedOrganizationNode != null) {
return this.activeFilter.selectedOrganizationNode.node.id;
if (this.filter.organizationId !== undefined) {
return this.filter.organizationId;
}
return undefined;
}
get title() {
if (this.activeFilter.isCollectionSelected) {
if (this.activeFilter.isUnassignedCollectionSelected) {
return this.i18nService.t("unassigned");
}
return this.activeFilter.selectedCollectionNode.node.name;
protected get activeOrganization() {
const organizationId = this.activeOrganizationId;
return this.organizations?.find((org) => org.id === organizationId);
}
protected get showBreadcrumbs() {
return this.filter.collectionId !== undefined && this.filter.collectionId !== All;
}
protected get title() {
if (this.filter.collectionId === Unassigned) {
return this.i18nService.t("unassigned");
}
if (this.activeFilter.isMyVaultSelected) {
if (this.collection) {
return this.collection.node.name;
}
if (this.filter.organizationId === Unassigned) {
return this.i18nService.t("myVault");
}
if (this.activeFilter?.selectedOrganizationNode != null) {
return `${this.activeFilter.selectedOrganizationNode.node.name} ${this.i18nService
.t("vault")
.toLowerCase()}`;
const activeOrganization = this.activeOrganization;
if (activeOrganization) {
return `${activeOrganization.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);
/**
* A list of collection filters that form a chain from the organization root to currently selected collection.
* Begins from the organization root and excludes the currently selected collection.
*/
protected get collections() {
if (this.collection == undefined) {
return [];
}
const collections = [this.collection];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections
.slice(1)
.reverse()
.map((treeNode) => treeNode.node);
}
addCipher() {
protected addCipher() {
this.onAddCipher.emit();
}
}

View File

@ -1,361 +0,0 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<ng-container>
<bit-table
*ngIf="filteredCiphers.length || filteredCollections.length"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th bitCell class="tw-min-w-fit" colspan="2">
<input
class="tw-mr-2"
type="checkbox"
bitCheckbox
id="checkAll"
[disabled]="isProcessingAction"
(change)="checkAll($any($event.target).checked)"
[(ngModel)]="isAllChecked"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="checkAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell class="tw-w-1/2">{{ "name" | i18n }}</th>
<th bitCell class="tw-w-max">
<ng-container *ngIf="!organization">{{ "owner" | i18n }}</ng-container>
<ng-container *ngIf="organization">
{{ (activeFilter.selectedCollectionNode ? "groups" : "collections") | i18n }}
</ng-container>
</th>
<th bitCell class="tw-min-w-fit tw-text-right">
<button
[bitMenuTriggerFor]="headerMenu"
[disabled]="isProcessingAction"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<ng-container>
<button
bitMenuItem
(click)="bulkMove()"
*ngIf="!activeFilter.isDeleted && !organization"
>
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }}
</button>
<button
bitMenuItem
(click)="bulkShare()"
*ngIf="!activeFilter.isDeleted && !organization"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveSelectedToOrg" | i18n }}
</button>
<button bitMenuItem (click)="bulkRestore()" *ngIf="activeFilter.isDeleted">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restoreSelected" | i18n }}
</button>
<button bitMenuItem (click)="bulkDelete()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{
(activeFilter.isDeleted ? "permanentlyDeleteSelected" : "deleteSelected") | i18n
}}
</span>
</button>
</ng-container>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body>
<tr
bitRow
*ngFor="let col of filteredCollections"
[class.tw-cursor-pointer]="!isProcessingAction"
(click)="!isProcessingAction && navigateCollection(col)"
alignContent="middle"
>
<td bitCell (click)="checkRow(col)" appStopProp>
<input
*ngIf="canDeleteCollection(col.node)"
class="tw-cursor-pointer"
type="checkbox"
bitCheckbox
[disabled]="isProcessingAction"
[(ngModel)]="$any(col).checked"
appStopProp
/>
</td>
<td bitCell>
<div class="icon" aria-hidden="true">
<i class="bwi bwi-fw bwi-lg bwi-collection"></i>
</div>
</td>
<td bitCell>
<button
bitLink
class="tw-text-start"
linkType="secondary"
(click)="navigateCollection(col)"
[disabled]="isProcessingAction"
>
{{ col.node.name }}
</button>
</td>
<td bitCell>
<ng-container *ngIf="!organization">
<app-org-badge
organizationName="{{ col.node.organizationId | orgNameFromId : organizations }}"
[profileName]="profileName"
(onOrganizationClicked)="onOrganizationClicked(col.node.organizationId)"
appStopProp
>
</app-org-badge>
</ng-container>
<ng-container *ngIf="organization && activeFilter.selectedCollectionNode">
<app-group-badge
*ngIf="col.node.groups"
[selectedGroups]="col.node.groups"
[allGroups]="groups"
></app-group-badge>
</ng-container>
</td>
<td bitCell class="tw-text-right">
<button
*ngIf="canEditCollection(col.node) || canDeleteCollection(col.node)"
[bitMenuTriggerFor]="collectionOptions"
[disabled]="isProcessingAction"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #collectionOptions>
<button
*ngIf="canEditCollection(col.node)"
bitMenuItem
(click)="editCollection(col.node, 'info')"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
*ngIf="canEditCollection(col.node)"
bitMenuItem
(click)="editCollection(col.node, 'access')"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
*ngIf="canDeleteCollection(col.node)"
bitMenuItem
(click)="deleteCollection(col.node)"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
<tr
bitRow
*ngFor="let c of filteredCiphers"
[class.tw-cursor-pointer]="!isProcessingAction"
(click)="!isProcessingAction && selectCipher(c)"
alignContent="middle"
>
<td bitCell (click)="checkRow(c)" appStopProp>
<input
type="checkbox"
bitCheckbox
[disabled]="isProcessingAction"
[(ngModel)]="$any(c).checked"
appStopProp
/>
</td>
<td bitCell>
<app-vault-icon [cipher]="c"></app-vault-icon>
</td>
<td bitCell class="tw-break-all">
<button
bitLink
class="tw-text-start"
[routerLink]="[]"
[queryParams]="{ itemId: c.id }"
queryParamsHandling="merge"
title="{{ 'editItem' | i18n }}"
[disabled]="isProcessingAction"
>
{{ c.name }}
</button>
<ng-container *ngIf="c.hasAttachments">
<i
class="bwi bwi-paperclip"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "attachments" | i18n }}</span>
<ng-container *ngIf="showFixOldAttachments(c)">
<i
class="bwi bwi-exclamation-triangle text-warning"
appStopProp
title="{{ 'attachmentsNeedFix' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "attachmentsNeedFix" | i18n }}</span>
</ng-container>
</ng-container>
<br />
<span class="tw-text-sm tw-text-muted" appStopProp>{{ c.subTitle }}</span>
</td>
<td bitCell>
<ng-container *ngIf="!organization">
<app-org-badge
organizationName="{{ c.organizationId | orgNameFromId : organizations }}"
profileName="{{ profileName }}"
(onOrganizationClicked)="onOrganizationClicked(c.organizationId)"
appStopProp
>
</app-org-badge>
</ng-container>
<ng-container *ngIf="organization && !activeFilter.selectedCollectionNode">
<app-collection-badge
*ngIf="c.collectionIds"
[collectionIds]="c.collectionIds"
[collections]="vaultFilterService.filteredCollections$ | async"
></app-collection-badge>
</ng-container>
</td>
<td bitCell class="tw-text-right">
<button
[bitMenuTriggerFor]="cipherOptions"
[disabled]="isProcessingAction"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="c.type === cipherType.Login && !c.isDeleted">
<button bitMenuItem (click)="copy(c, c.login.username, 'username', 'Username')">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button
bitMenuItem
(click)="copy(c, c.login.password, 'password', 'Password')"
*ngIf="c.viewPassword"
>
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
<button
bitMenuItem
(click)="copy(c, c.login.totp, 'verificationCodeTotp', 'TOTP')"
*ngIf="displayTotpCopyButton(c)"
>
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }}
</button>
<button bitMenuItem *ngIf="c.login.canLaunch" (click)="launch(c.login.launchUri)">
<i class="bwi bwi-fw bwi-share-square" aria-hidden="true"></i>
{{ "launch" | i18n }}
</button>
</ng-container>
<button bitMenuItem (click)="attachments(c)">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
<button
bitMenuItem
*ngIf="((!organization && !c.organizationId) || organization) && !c.isDeleted"
(click)="clone(c)"
>
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
<button
bitMenuItem
*ngIf="!organization && !c.organizationId && !c.isDeleted"
(click)="share(c)"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveToOrganization" | i18n }}
</button>
<button
bitMenuItem
*ngIf="c.organizationId && !c.isDeleted"
(click)="editCipherCollections(c)"
>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collections" | i18n }}
</button>
<button bitMenuItem *ngIf="c.organizationId && accessEvents" (click)="events(c)">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button bitMenuItem (click)="restore(c)" *ngIf="c.isDeleted">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button bitMenuItem (click)="deleteCipher(c)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (c.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="
showMissingCollectionPermissionMessage ||
(!filteredCiphers.length && !filteredCollections.length)
"
>
<ng-container *ngIf="!loaded">
<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>
<ng-container *ngIf="loaded">
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<ng-container *ngIf="showMissingCollectionPermissionMessage">
<p>{{ "noPermissionToViewAllCollectionItems" | i18n }}</p>
</ng-container>
<ng-container *ngIf="!showMissingCollectionPermissionMessage">
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="showAddNew"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</ng-container>
</ng-container>
</div>
</ng-container>

View File

@ -1,588 +0,0 @@
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EventType } from "@bitwarden/common/enums";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, Icons } from "@bitwarden/components";
import { CollectionAdminView, GroupView } from "../../admin-console/organizations/core";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import {
BulkRestoreDialogResult,
openBulkRestoreDialog,
} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component";
import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "./vault-filter/shared/models/vault-filter.type";
const MaxCheckedCount = 500;
export type VaultItemRow = (CipherView | TreeNode<CollectionFilter>) & { checked?: boolean };
@Component({
selector: "app-vault-items",
templateUrl: "vault-items.component.html",
})
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
@Output() onAttachmentsClicked = new EventEmitter<CipherView>();
@Output() onShareClicked = new EventEmitter<CipherView>();
@Output() onEditCipherCollectionsClicked = new EventEmitter<CipherView>();
@Output() onCloneClicked = new EventEmitter<CipherView>();
@Output() onOrganzationBadgeClicked = new EventEmitter<string>();
private _activeFilter: VaultFilter;
@Input() get activeFilter(): VaultFilter {
return this._activeFilter;
}
set activeFilter(value: VaultFilter) {
this._activeFilter = value;
this.reload(this.activeFilter.buildFilter(), this.activeFilter.isDeleted);
}
cipherType = CipherType;
actionPromise: Promise<any>;
userHasPremiumAccess = false;
organizations: Organization[] = [];
profileName: string;
noItemIcon = Icons.Search;
groups: GroupView[] = [];
protected pageSizeLimit = 200;
protected isAllChecked = false;
protected didScroll = false;
protected currentPagedCiphersCount = 0;
protected currentPagedCollectionsCount = 0;
protected refreshing = false;
protected pagedCiphers: CipherView[] = [];
protected pagedCollections: TreeNode<CollectionFilter>[] = [];
protected searchedCollections: TreeNode<CollectionFilter>[] = [];
get showAddNew() {
return !this.activeFilter.isDeleted;
}
get collections(): TreeNode<CollectionFilter>[] {
return this.activeFilter?.selectedCollectionNode?.children ?? [];
}
get filteredCollections(): TreeNode<CollectionFilter>[] {
if (this.isPaging()) {
return this.pagedCollections;
}
if (this.searchService.isSearchable(this.searchText)) {
return this.searchedCollections;
}
return this.collections;
}
get filteredCiphers(): CipherView[] {
return this.isPaging() ? this.pagedCiphers : this.ciphers;
}
constructor(
searchService: SearchService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected vaultFilterService: VaultFilterService,
cipherService: CipherService,
protected eventCollectionService: EventCollectionService,
protected totpService: TotpService,
protected stateService: StateService,
protected passwordRepromptService: PasswordRepromptService,
protected dialogService: DialogService,
protected logService: LogService,
private searchPipe: SearchPipe,
private organizationService: OrganizationService,
private tokenService: TokenService
) {
super(searchService, cipherService);
}
ngOnDestroy() {
this.checkAll(false);
}
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
this.checkAll(false);
this.isAllChecked = false;
this.pagedCollections = [];
if (!this.refreshing && this.isPaging()) {
this.currentPagedCollectionsCount = 0;
this.currentPagedCiphersCount = 0;
}
await super.applyFilter(filter);
}
// load() is called after the page loads and the first sync has completed.
// Do not use ngOnInit() for anything that requires sync data.
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
await super.load(filter, deleted);
this.updateSearchedCollections(this.collections);
this.profileName = await this.tokenService.getName();
this.organizations = await this.organizationService.getAll();
this.userHasPremiumAccess = await this.stateService.getCanAccessPremium();
}
async refresh() {
try {
this.refreshing = true;
await this.reload(this.filter, this.deleted);
} finally {
this.refreshing = false;
}
}
loadMore() {
// If we have less rows than the page size, we don't need to page anything
if (this.ciphers.length + (this.collections?.length || 0) <= this.pageSizeLimit) {
return;
}
let pageSpaceLeft = this.pageSizeLimit;
if (
this.refreshing &&
this.pagedCiphers.length + this.pagedCollections.length === 0 &&
this.currentPagedCiphersCount + this.currentPagedCollectionsCount > this.pageSizeLimit
) {
// When we refresh, we want to load the previous amount of items, not restart the paging
pageSpaceLeft = this.currentPagedCiphersCount + this.currentPagedCollectionsCount;
}
// if there are still collections to show
if (this.collections?.length > this.pagedCollections.length) {
const collectionsToAdd = this.collections.slice(
this.pagedCollections.length,
this.currentPagedCollectionsCount + pageSpaceLeft
);
this.pagedCollections = this.pagedCollections.concat(collectionsToAdd);
// set the current count to the new count of paged collections
this.currentPagedCollectionsCount = this.pagedCollections.length;
// subtract the available page size by the amount of collections we just added, default to 0 if negative
pageSpaceLeft =
collectionsToAdd.length > pageSpaceLeft ? 0 : pageSpaceLeft - collectionsToAdd.length;
}
// if we have room left to show ciphers and we have ciphers to show
if (pageSpaceLeft > 0 && this.ciphers.length > this.pagedCiphers.length) {
this.pagedCiphers = this.pagedCiphers.concat(
this.ciphers.slice(this.pagedCiphers.length, this.currentPagedCiphersCount + pageSpaceLeft)
);
// set the current count to the new count of paged ciphers
this.currentPagedCiphersCount = this.pagedCiphers.length;
}
// set a flag if we actually loaded the second page while paging
this.didScroll = this.pagedCiphers.length + this.pagedCollections.length > this.pageSizeLimit;
}
isPaging() {
const searching = this.isSearching();
if (searching && this.didScroll) {
this.resetPaging();
}
const totalRows =
this.ciphers.length + (this.activeFilter?.selectedCollectionNode?.children.length || 0);
return !searching && totalRows > this.pageSizeLimit;
}
async resetPaging() {
this.pagedCollections = [];
this.pagedCiphers = [];
this.loadMore();
}
async doSearch(indexedCiphers?: CipherView[]) {
indexedCiphers = indexedCiphers ?? (await this.cipherService.getAllDecrypted());
this.ciphers = await this.searchService.searchCiphers(
this.searchText,
[this.filter, this.deletedFilter],
indexedCiphers
);
this.updateSearchedCollections(this.collections);
this.resetPaging();
}
launch(uri: string) {
this.platformUtilsService.launchUri(uri);
}
async attachments(c: CipherView) {
if (!(await this.repromptCipher(c))) {
return;
}
this.onAttachmentsClicked.emit(c);
}
async share(c: CipherView) {
if (!(await this.repromptCipher(c))) {
return;
}
this.onShareClicked.emit(c);
}
editCipherCollections(c: CipherView) {
this.onEditCipherCollectionsClicked.emit(c);
}
async clone(c: CipherView) {
if (!(await this.repromptCipher(c))) {
return;
}
this.onCloneClicked.emit(c);
}
async deleteCipher(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher(c))) {
return;
}
if (this.actionPromise != null) {
return;
}
const permanent = c.isDeleted;
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t(
permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation"
),
this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.actionPromise = this.deleteCipherWithServer(c.id, permanent);
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem")
);
this.refresh();
} catch (e) {
this.logService.error(e);
}
this.actionPromise = null;
}
async bulkDelete() {
if (!(await this.repromptCipher())) {
return;
}
const selectedIds = this.selectedCipherIds;
if (selectedIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: { permanent: this.deleted, cipherIds: selectedIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async restore(c: CipherView): Promise<boolean> {
if (this.actionPromise != null || !c.isDeleted) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("restoreItemConfirmation"),
this.i18nService.t("restoreItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.actionPromise = this.cipherService.restoreWithServer(c.id);
await this.actionPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem"));
this.refresh();
} catch (e) {
this.logService.error(e);
}
this.actionPromise = null;
}
async bulkRestore() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkRestoreDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkRestoreDialogResult.Restored) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async bulkShare() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCiphers = this.selectedCiphers;
if (selectedCiphers.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } });
const result = await lastValueFrom(dialog.closed);
if (result === BulkShareDialogResult.Shared) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async bulkMove() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkMoveDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkMoveDialogResult.Moved) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) {
if (
this.passwordRepromptService.protectedFields().includes(aType) &&
!(await this.repromptCipher(cipher))
) {
return;
}
if (value == null || (aType === "TOTP" && !this.displayTotpCopyButton(cipher))) {
return;
} else if (value === cipher.login.totp) {
value = await this.totpService.getCode(value);
}
if (!cipher.viewPassword) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey))
);
if (typeI18nKey === "password" || typeI18nKey === "verificationCodeTotp") {
this.eventCollectionService.collect(
EventType.Cipher_ClientToggledHiddenFieldVisible,
cipher.id
);
} else if (typeI18nKey === "securityCode") {
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id);
}
}
navigateCollection(node: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.selectedCollectionNode = node;
this.activeFilterChanged.emit(filter);
}
checkAll(select: boolean) {
if (select) {
this.checkAll(false);
}
const items: VaultItemRow[] = this.ciphers;
if (!items) {
return;
}
const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length;
for (let i = 0; i < selectCount; i++) {
this.checkRow(items[i], select);
}
}
checkRow(item: VaultItemRow, select?: boolean) {
// Collections can't be managed in end user vault
if (!(item instanceof CipherView)) {
return;
}
item.checked = select ?? !item.checked;
}
get selectedCiphers(): CipherView[] {
if (!this.ciphers) {
return [];
}
return this.ciphers.filter((c) => !!(c as VaultItemRow).checked);
}
get selectedCipherIds(): string[] {
return this.selectedCiphers.map((c) => c.id);
}
displayTotpCopyButton(cipher: CipherView) {
return (
(cipher?.login?.hasTotp ?? false) && (cipher.organizationUseTotp || this.userHasPremiumAccess)
);
}
onOrganizationClicked(organizationId: string) {
this.onOrganzationBadgeClicked.emit(organizationId);
}
events(c: CipherView) {
// TODO: This should be removed but is needed since we reuse the same template
}
canDeleteCollection(c: CollectionAdminView): boolean {
// TODO: This should be removed but is needed since we reuse the same template
return false; // Always return false for non org vault
}
async deleteCollection(collection: CollectionView): Promise<void> {
// TODO: This should be removed but is needed since we reuse the same template
}
canEditCollection(c: CollectionAdminView): boolean {
// TODO: This should be removed but is needed since we reuse the same template
return false; // Always return false for non org vault
}
async editCollection(c: CollectionView, tab: "info" | "access"): Promise<void> {
// TODO: This should be removed but is needed since we reuse the same template
}
get showMissingCollectionPermissionMessage(): boolean {
// TODO: This should be removed but is needed since we reuse the same template
return false; // Always return false for non org vault
}
/**
* @deprecated Block interaction using long running modal dialog instead
*/
protected get isProcessingAction() {
return this.actionPromise != null;
}
protected updateSearchedCollections(collections: TreeNode<CollectionFilter>[]) {
if (this.searchService.isSearchable(this.searchText)) {
this.searchedCollections = this.searchPipe.transform(
collections,
this.searchText,
(collection) => collection.node.name,
(collection) => collection.node.id
);
}
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id)
: this.cipherService.softDeleteWithServer(id);
}
protected showFixOldAttachments(c: CipherView) {
return c.hasOldAttachments && c.organizationId == null;
}
protected async repromptCipher(c?: CipherView) {
if (c) {
return (
c.reprompt === CipherRepromptType.None ||
(await this.passwordRepromptService.showPasswordPrompt())
);
} else {
const selectedCiphers = this.selectedCiphers;
const notProtected = !selectedCiphers.find(
(cipher) => cipher.reprompt !== CipherRepromptType.None
);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
}
}

View File

@ -17,24 +17,61 @@
</div>
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
<app-vault-header
[activeFilter]="activeFilter"
[actionPromise]="vaultItemsComponent.actionPromise"
[filter]="filter"
[loading]="refreshing && !performingInitialLoad"
[organizations]="allOrganizations"
[collection]="selectedCollection"
(onAddCipher)="addCipher()"
></app-vault-header>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
{{ trashCleanupWarning }}
</app-callout>
<app-vault-items
[activeFilter]="activeFilter"
(onCipherClicked)="navigateToCipher($event)"
(onAttachmentsClicked)="editCipherAttachments($event)"
(onAddCipher)="addCipher()"
(onShareClicked)="shareCipher($event)"
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
(onCloneClicked)="cloneCipher($event)"
(onOrganzationBadgeClicked)="applyOrganizationFilter($event)"
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="allOrganizations"
[disabled]="refreshing"
[showOwner]="true"
[showCollections]="false"
[showGroups]="false"
[showPremiumFeatures]="canAccessPremium"
[showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false"
[editableCollections]="false"
[cloneableOrganizationCiphers]="false"
(onEvent)="onVaultItemsEvent($event)"
>
</app-vault-items>
<div
*ngIf="performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div
*ngIf="isEmpty && !performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash'"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</div>
</div>
<div class="col-3">
<div class="card border-warning mb-4" *ngIf="showUpdateKey">
@ -96,5 +133,5 @@
<ng-template #folderAddEdit></ng-template>
<ng-template #cipherAddEdit></ng-template>
<ng-template #share></ng-template>
<ng-template #collections></ng-template>
<ng-template #collectionsModal></ng-template>
<ng-template #updateKeyTemplate></ng-template>

View File

@ -8,30 +8,69 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators";
import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs";
import {
concatMap,
debounceTime,
filter,
first,
map,
shareReplay,
switchMap,
takeUntil,
tap,
} from "rxjs/operators";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { KdfType, DEFAULT_PBKDF2_ITERATIONS } from "@bitwarden/common/enums";
import { KdfType, DEFAULT_PBKDF2_ITERATIONS, EventType } from "@bitwarden/common/enums";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { Utils } from "@bitwarden/common/misc/utils";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, Icons } from "@bitwarden/components";
import { UpdateKeyComponent } from "../../settings/update-key.component";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { getNestedCollectionTree } from "../utils/collection-utils";
import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import {
BulkRestoreDialogResult,
openBulkRestoreDialog,
} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component";
import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { CollectionsComponent } from "./collections.component";
import { FolderAddEditComponent } from "./folder-add-edit.component";
import { ShareComponent } from "./share.component";
@ -39,11 +78,17 @@ import { VaultFilterComponent } from "./vault-filter/components/vault-filter.com
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service";
import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.service";
import { createFilterFunction } from "./vault-filter/shared/models/filter-function";
import {
All,
RoutedVaultFilterModel,
Unassigned,
} from "./vault-filter/shared/models/routed-vault-filter.model";
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
import { VaultItemsComponent } from "./vault-items.component";
const BroadcasterSubscriptionId = "VaultComponent";
const SearchTextDebounceInterval = 200;
@Component({
selector: "app-vault",
@ -52,7 +97,6 @@ const BroadcasterSubscriptionId = "VaultComponent";
})
export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
@ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent;
@ViewChild("attachments", { read: ViewContainerRef, static: true })
attachmentsModalRef: ViewContainerRef;
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
@ -60,7 +104,7 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
cipherAddEditModalRef: ViewContainerRef;
@ViewChild("share", { read: ViewContainerRef, static: true }) shareModalRef: ViewContainerRef;
@ViewChild("collections", { read: ViewContainerRef, static: true })
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
collectionsModalRef: ViewContainerRef;
@ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true })
updateKeyModalRef: ViewContainerRef;
@ -73,6 +117,23 @@ export class VaultComponent implements OnInit, OnDestroy {
trashCleanupWarning: string = null;
kdfIterations: number;
activeFilter: VaultFilter = new VaultFilter();
protected noItemIcon = Icons.Search;
protected performingInitialLoad = true;
protected refreshing = false;
protected processingEvent = false;
protected filter: RoutedVaultFilterModel = {};
protected showBulkMove: boolean;
protected canAccessPremium: boolean;
protected allCollections: CollectionView[];
protected allOrganizations: Organization[];
protected ciphers: CipherView[];
protected collections: CollectionView[];
protected isEmpty: boolean;
protected selectedCollection: TreeNode<CollectionView> | undefined;
private refresh$ = new BehaviorSubject<void>(null);
private searchText$ = new Subject<string>();
private destroy$ = new Subject<void>();
constructor(
@ -82,6 +143,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private i18nService: I18nService,
private modalService: ModalService,
private dialogService: DialogService,
private tokenService: TokenService,
private cryptoService: CryptoService,
private messagingService: MessagingService,
@ -91,47 +153,169 @@ export class VaultComponent implements OnInit, OnDestroy {
private stateService: StateService,
private organizationService: OrganizationService,
private vaultFilterService: VaultFilterService,
private routedVaultFilterService: RoutedVaultFilterService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService
private passwordRepromptService: PasswordRepromptService,
private collectionService: CollectionService,
private logService: LogService,
private totpService: TotpService,
private eventCollectionService: EventCollectionService,
private searchService: SearchService,
private searchPipe: SearchPipe
) {}
async ngOnInit() {
this.showVerifyEmail = !(await this.tokenService.getEmailVerified());
this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1;
// disable warning for March release -> add await this.isLowKdfIteration(); when ready
this.showLowKdf = false;
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
: "trashCleanupWarning"
);
this.route.queryParams
const firstSetup$ = this.route.queryParams.pipe(
first(),
switchMap(async (params: Params) => {
this.showVerifyEmail = !(await this.tokenService.getEmailVerified());
// disable warning for March release -> add await this.isLowKdfIteration(); when ready
this.showLowKdf = false;
await this.syncService.fullSync(false);
const canAccessPremium = await this.stateService.getCanAccessPremium();
this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
const cipherId = getCipherIdFromParams(params);
if (!cipherId) {
return;
}
const cipherView = new CipherView();
cipherView.id = cipherId;
if (params.action === "clone") {
await this.cloneCipher(cipherView);
} else if (params.action === "edit") {
await this.editCipher(cipherView);
}
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
this.refresh();
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => {
this.activeFilter = activeFilter;
});
const filter$ = this.routedVaultFilterService.filter$;
const canAccessPremium$ = Utils.asyncToObservable(() =>
this.stateService.getCanAccessPremium()
).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const allCollections$ = Utils.asyncToObservable(() =>
this.collectionService.getAllDecrypted()
).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
shareReplay({ refCount: true, bufferSize: 1 })
);
this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$))
.subscribe((searchText) =>
this.router.navigate([], {
queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText },
queryParamsHandling: "merge",
replaceUrl: true,
})
);
const querySearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const ciphers$ = combineLatest([
Utils.asyncToObservable(() => this.cipherService.getAllDecrypted()),
filter$,
querySearchText$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
const filterFunction = createFilterFunction(filter);
if (this.searchService.isSearchable(searchText)) {
return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers);
}
return ciphers.filter(filterFunction);
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
const collections$ = combineLatest([nestedCollections$, filter$, querySearchText$]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined),
map(([collections, filter, searchText]) => {
if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
return [];
}
let collectionsToReturn = [];
if (filter.organizationId !== undefined && filter.collectionId === All) {
collectionsToReturn = collections
.filter((c) => c.node.organizationId === filter.organizationId)
.map((c) => c.node);
} else if (filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
}
if (this.searchService.isSearchable(searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,
(collection) => collection.name,
(collection) => collection.id
);
}
return collectionsToReturn;
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined),
map(([collections, filter]) => {
if (
filter.collectionId === undefined ||
filter.collectionId === All ||
filter.collectionId === Unassigned
) {
return undefined;
}
return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId);
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
firstSetup$
.pipe(
first(),
switchMap(async (params: Params) => {
await this.syncService.fullSync(false);
await this.vaultFilterService.reloadCollections();
await this.vaultItemsComponent.reload();
const canAccessPremium = await this.stateService.getCanAccessPremium();
this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
const cipherId = getCipherIdFromParams(params);
if (!cipherId) {
return;
}
const cipherView = new CipherView();
cipherView.id = cipherId;
if (params.action === "clone") {
await this.cloneCipher(cipherView);
} else if (params.action === "edit") {
await this.editCipher(cipherView);
}
}),
switchMap(() => this.route.queryParams),
switchMap(async (params) => {
const cipherId = getCipherIdFromParams(params);
@ -155,27 +339,54 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.subscribe();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await Promise.all([
this.vaultFilterService.reloadCollections(),
this.vaultItemsComponent.load(this.vaultItemsComponent.filter),
]);
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
firstSetup$
.pipe(
switchMap(() => this.refresh$),
tap(() => (this.refreshing = true)),
switchMap(() =>
combineLatest([
filter$,
canAccessPremium$,
allCollections$,
this.organizationService.organizations$,
ciphers$,
collections$,
selectedCollection$,
])
),
takeUntil(this.destroy$)
)
.subscribe(
([
filter,
canAccessPremium,
allCollections,
allOrganizations,
ciphers,
collections,
selectedCollection,
]) => {
this.filter = filter;
this.canAccessPremium = canAccessPremium;
this.allCollections = allCollections;
this.allOrganizations = allOrganizations;
this.ciphers = ciphers;
this.collections = collections;
this.selectedCollection = selectedCollection;
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => {
this.activeFilter = activeFilter;
});
this.showBulkMove =
filter.type !== "trash" &&
(filter.organizationId === undefined || filter.organizationId === Unassigned);
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
// This is a temporary fix to avoid double fetching collections.
// TODO: Remove when implementing new VVR menu
this.vaultFilterService.reloadCollections(allCollections);
this.performingInitialLoad = false;
this.refreshing = false;
}
);
}
get isShowingCards() {
@ -198,6 +409,44 @@ export class VaultComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async onVaultItemsEvent(event: VaultItemEvent) {
this.processingEvent = true;
try {
if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") {
await this.editCipherCollections(event.item);
} else if (event.type === "clone") {
await this.cloneCipher(event.item);
} else if (event.type === "restore") {
if (event.items.length === 1) {
await this.restore(event.items[0]);
} else {
await this.bulkRestore(event.items);
}
} else if (event.type === "delete") {
const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher);
if (ciphers.length === 1) {
await this.deleteCipher(ciphers[0]);
} else {
await this.bulkDelete(ciphers);
}
} else if (event.type === "moveToFolder") {
await this.bulkMove(event.items);
} else if (event.type === "moveToOrganization") {
if (event.items.length === 1) {
await this.shareCipher(event.items[0]);
} else {
await this.bulkShare(event.items);
}
} else if (event.type === "copyField") {
await this.copy(event.item, event.field);
}
} finally {
this.processingEvent = false;
}
}
async applyOrganizationFilter(orgId: string) {
if (orgId == null) {
orgId = "MyVault";
@ -213,8 +462,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.folderAddEditModalRef,
(comp) => {
comp.folderId = null;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedFolder.subscribe(async () => {
comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
});
}
@ -227,12 +475,18 @@ export class VaultComponent implements OnInit, OnDestroy {
this.folderAddEditModalRef,
(comp) => {
comp.folderId = folder.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedFolder.subscribe(async () => {
comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onDeletedFolder.subscribe(async () => {
comp.onDeletedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
// Navigate away if we deleted the colletion we were viewing
if (this.filter.folderId === folder.id) {
this.router.navigate([], {
queryParams: { folderId: null },
queryParamsHandling: "merge",
replaceUrl: true,
});
}
modal.close();
});
}
@ -240,8 +494,7 @@ export class VaultComponent implements OnInit, OnDestroy {
};
filterSearchText(searchText: string) {
this.vaultItemsComponent.searchText = searchText;
this.vaultItemsComponent.search(200);
this.searchText$.next(searchText);
}
async editCipherAttachments(cipher: CipherView) {
@ -265,19 +518,21 @@ export class VaultComponent implements OnInit, OnDestroy {
this.attachmentsModalRef,
(comp) => {
comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true));
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onReuploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
comp.onUploadedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
comp.onDeletedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
comp.onReuploadedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
}
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
modal.onClosed.subscribe(async () => {
modal.onClosed.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (madeAttachmentChanges) {
await this.vaultItemsComponent.refresh();
this.refresh();
}
madeAttachmentChanges = false;
});
@ -289,10 +544,9 @@ export class VaultComponent implements OnInit, OnDestroy {
this.shareModalRef,
(comp) => {
comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSharedCipher.subscribe(async () => {
comp.onSharedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
}
);
@ -304,10 +558,9 @@ export class VaultComponent implements OnInit, OnDestroy {
this.collectionsModalRef,
(comp) => {
comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedCollections.subscribe(async () => {
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
}
);
@ -351,20 +604,17 @@ export class VaultComponent implements OnInit, OnDestroy {
this.cipherAddEditModalRef,
(comp) => {
comp.cipherId = id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedCipher.subscribe(async () => {
comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onDeletedCipher.subscribe(async () => {
comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onRestoredCipher.subscribe(async () => {
comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
}
);
@ -381,6 +631,216 @@ export class VaultComponent implements OnInit, OnDestroy {
component.cloneMode = true;
}
async restore(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
return;
}
if (!c.isDeleted) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("restoreItemConfirmation"),
this.i18nService.t("restoreItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.cipherService.restoreWithServer(c.id);
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem"));
this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkRestore(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkRestoreDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkRestoreDialogResult.Restored) {
this.refresh();
}
}
async deleteCipher(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
return;
}
const permanent = c.isDeleted;
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t(
permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation"
),
this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.deleteCipherWithServer(c.id, permanent);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem")
);
this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkDelete(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedIds = ciphers.map((cipher) => cipher.id);
if (selectedIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: { permanent: this.filter.type === "trash", cipherIds: selectedIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.refresh();
}
}
async bulkMove(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkMoveDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkMoveDialogResult.Moved) {
this.refresh();
}
}
async copy(cipher: CipherView, field: "username" | "password" | "totp") {
let aType;
let value;
let typeI18nKey;
if (field === "username") {
aType = "Username";
value = cipher.login.username;
typeI18nKey = "username";
} else if (field === "password") {
aType = "Password";
value = cipher.login.password;
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
value = await this.totpService.getCode(cipher.login.totp);
typeI18nKey = "verificationCodeTotp";
} else {
this.platformUtilsService.showToast("info", null, this.i18nService.t("unexpectedError"));
return;
}
if (
this.passwordRepromptService.protectedFields().includes(aType) &&
!(await this.repromptCipher([cipher]))
) {
return;
}
if (!cipher.viewPassword) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey))
);
if (field === "password" || field === "totp") {
this.eventCollectionService.collect(
EventType.Cipher_ClientToggledHiddenFieldVisible,
cipher.id
);
}
}
async bulkShare(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
if (ciphers.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers } });
const result = await lastValueFrom(dialog.closed);
if (result === BulkShareDialogResult.Shared) {
this.refresh();
}
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id)
: this.cipherService.softDeleteWithServer(id);
}
async updateKey() {
await this.modalService.openViewRef(UpdateKeyComponent, this.updateKeyModalRef);
}
@ -391,6 +851,16 @@ export class VaultComponent implements OnInit, OnDestroy {
return kdfType === KdfType.PBKDF2_SHA256 && kdfOptions.iterations < DEFAULT_PBKDF2_ITERATIONS;
}
protected async repromptCipher(ciphers: CipherView[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
private refresh() {
this.refresh$.next();
}
private go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {

View File

@ -3,6 +3,7 @@ import { NgModule } from "@angular/core";
import { BreadcrumbsModule } from "@bitwarden/components";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { CollectionBadgeModule } from "../org-vault/collection-badge/collection-badge.module";
import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module";
@ -11,7 +12,6 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge
import { PipesModule } from "./pipes/pipes.module";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
import { VaultItemsComponent } from "./vault-items.component";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
@ -27,8 +27,9 @@ import { VaultComponent } from "./vault.component";
LooseComponentsModule,
BulkDialogsModule,
BreadcrumbsModule,
VaultItemsModule,
],
declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent],
})
export class VaultModule {}

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { SharedModule } from "../../../shared/shared.module";
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { SharedModule } from "../../../shared/shared.module";
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
import { GroupNameBadgeComponent } from "./group-name-badge.component";

View File

@ -1,21 +1,18 @@
import { Injectable, OnDestroy } from "@angular/core";
import { filter, map, Observable, ReplaySubject, Subject, switchMap, takeUntil } from "rxjs";
import { map, Observable, ReplaySubject, Subject } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import {
canAccessVaultTab,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CollectionAdminView } from "../../../admin-console/organizations/core";
import { CollectionAdminService } from "../../../admin-console/organizations/core/services/collection-admin.service";
import {
CollectionAdminService,
CollectionAdminView,
} from "../../../admin-console/organizations/core";
import { StateService } from "../../../core";
import { VaultFilterService as BaseVaultFilterService } from "../../individual-vault/vault-filter/services/vault-filter.service";
import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type";
@ -35,7 +32,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
organizationService: OrganizationService,
folderService: FolderService,
cipherService: CipherService,
collectionService: CollectionService,
policyService: PolicyService,
i18nService: I18nService,
protected collectionAdminService: CollectionAdminService
@ -45,42 +41,13 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
organizationService,
folderService,
cipherService,
collectionService,
policyService,
i18nService
);
this.loadSubscriptions();
}
protected loadSubscriptions() {
this._organizationFilter
.pipe(
filter((org) => org != null),
switchMap((org) => {
return this.loadCollections(org);
}),
takeUntil(this.destroy$)
)
.subscribe((collections) => {
this._collections.next(collections);
});
}
async reloadCollections() {
this._collections.next(await this.loadCollections(this._organizationFilter.getValue()));
}
protected async loadCollections(org: Organization): Promise<CollectionAdminView[]> {
let collections: CollectionAdminView[] = [];
if (canAccessVaultTab(org)) {
collections = await this.collectionAdminService.getAll(org.id);
const noneCollection = new CollectionAdminView();
noneCollection.name = this.i18nService.t("unassigned");
noneCollection.organizationId = org.id;
collections.push(noneCollection);
}
return collections;
async reloadCollections(collections: CollectionAdminView[]) {
this._collections.next(collections);
}
ngOnDestroy() {

View File

@ -1,29 +1,33 @@
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
<div>
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
<bit-breadcrumbs *ngIf="showBreadcrumbs">
<bit-breadcrumb
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
[route]="[]"
[queryParams]="{ organizationId: organization.id, collectionId: null }"
queryParamsHandling="merge"
>
<!-- 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>
{{ organization.name }} {{ "vault" | i18n | lowercase }}
</bit-breadcrumb>
<ng-container>
<bit-breadcrumb
*ngFor="let collection of collections"
icon="bwi-collection"
[route]="[]"
[queryParams]="{ collectionId: collection.id }"
queryParamsHandling="merge"
>
{{ collection.name }}
</bit-breadcrumb>
</ng-container>
</bit-breadcrumbs>
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
<i
*ngIf="activeFilter.isCollectionSelected"
*ngIf="filter.collectionId !== undefined"
class="bwi bwi-collection"
aria-hidden="true"
></i>
<span>{{ title }}</span>
<ng-container
*ngIf="activeFilter.isCollectionSelected && !activeFilter.isUnassignedCollectionSelected"
>
<ng-container *ngIf="collection !== undefined && (canEditCollection || canDeleteCollection)">
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
@ -34,27 +38,27 @@
<bit-menu #editCollectionMenu>
<button
type="button"
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'info')"
(click)="editCollection(CollectionDialogTabType.Info)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
*ngIf="canEditCollection"
bitMenuItem
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'access')"
(click)="editCollection(CollectionDialogTabType.Access)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
type="button"
*ngIf="canDeleteCollection(activeFilter.selectedCollectionNode.node)"
*ngIf="canDeleteCollection"
bitMenuItem
(click)="deleteCollection(activeFilter.selectedCollectionNode.node)"
(click)="deleteCollection()"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
@ -63,20 +67,18 @@
</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 *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</small>
</h1>
</div>
<div *ngIf="!activeFilter.isDeleted" class="tw-shrink-0">
<div *ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned" class="tw-shrink-0">
<div *ngIf="organization.canCreateNewCollections" appListDropdown>
<button
bitButton

View File

@ -1,14 +1,10 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { firstValueFrom } 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 { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { ProductType } from "@bitwarden/common/enums";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import {
@ -22,90 +18,95 @@ import {
CollectionAdminService,
CollectionAdminView,
} from "../../../admin-console/organizations/core";
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared";
import {
CollectionDialogResult,
CollectionDialogTabType,
openCollectionDialog,
} from "../../../admin-console/organizations/shared/components/collection-dialog";
import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type";
All,
RoutedVaultFilterModel,
Unassigned,
} from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
@Component({
selector: "app-org-vault-header",
templateUrl: "./vault-header.component.html",
})
export class VaultHeaderComponent {
protected All = All;
protected Unassigned = Unassigned;
/**
* The organization currently being viewed
* Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true
*/
@Input() loading: boolean;
/** Current active fitler */
@Input() filter: RoutedVaultFilterModel;
/** 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>;
/** Currently selected collection */
@Input() collection?: TreeNode<CollectionAdminView>;
/**
* 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
*/
/** Emits an event when the new item button is clicked in the header */
@Output() onAddCipher = new EventEmitter<void>();
/** Emits an event when the new collection button is clicked in the header */
@Output() onAddCollection = new EventEmitter<void>();
/** Emits an event when the edit collection button is clicked in the header */
@Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType }>();
/** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>();
protected CollectionDialogTabType = CollectionDialogTabType;
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.collection !== undefined) {
return this.collection.node.name;
}
if (this.activeFilter.isUnassignedCollectionSelected) {
if (this.filter.collectionId === Unassigned) {
return this.i18nService.t("unassigned");
}
return `${this.organization.name} ${this.i18nService.t("vault").toLowerCase()}`;
}
protected get showBreadcrumbs() {
return this.filter.collectionId !== undefined && this.filter.collectionId !== All;
}
/**
* A list of collection filters that form a chain from the organization root to currently selected collection.
* Begins from the organization root and excludes the currently selected collection.
*/
protected get collections() {
if (this.collection == undefined) {
return [];
}
const collections = [this.collection];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections
.slice(1)
.reverse()
.map((treeNode) => treeNode.node);
}
private showFreeOrgUpgradeDialog(): void {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
@ -140,23 +141,16 @@ export class VaultHeaderComponent {
});
}
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) {
get canEditCollection(): boolean {
// Only edit collections if not editing "Unassigned"
if (this.collection === undefined) {
return false;
}
// Otherwise, check if we can edit the specified collection
return (
this.organization.canEditAnyCollection ||
(this.organization.canEditAssignedCollections && c.assigned)
(this.organization.canEditAssignedCollections && this.collection?.node.assigned)
);
}
@ -173,77 +167,27 @@ export class VaultHeaderComponent {
}
}
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);
}
this.onAddCollection.emit();
}
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);
}
async editCollection(tab: CollectionDialogTabType): Promise<void> {
this.onEditCollection.emit({ tab });
}
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) {
get canDeleteCollection(): boolean {
// Only delete collections if not deleting "Unassigned"
if (this.collection === undefined) {
return false;
}
// Otherwise, check if we can delete the specified collection
return (
this.organization?.canDeleteAnyCollection ||
(this.organization?.canDeleteAssignedCollections && c.assigned)
(this.organization?.canDeleteAssignedCollections && this.collection.node.assigned)
);
}
async deleteCollection(collection: CollectionView): Promise<void> {
if (
!this.organization.canDeleteAnyCollection &&
!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);
}
deleteCollection() {
this.onDeleteCollection.emit();
}
}

View File

@ -1,333 +0,0 @@
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { CollectionAdminView } from "../../admin-console/organizations/core";
import { GroupService } from "../../admin-console/organizations/core/services/group/group.service";
import {
CollectionDialogResult,
CollectionDialogTabType,
openCollectionDialog,
} from "../../admin-console/organizations/shared/components/collection-dialog";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "../individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import { VaultFilterService } from "../individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { CollectionFilter } from "../individual-vault/vault-filter/shared/models/vault-filter.type";
import {
VaultItemRow,
VaultItemsComponent as BaseVaultItemsComponent,
} from "../individual-vault/vault-items.component";
const MaxCheckedCount = 500;
@Component({
selector: "app-org-vault-items",
templateUrl: "../individual-vault/vault-items.component.html",
})
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
@Input() set initOrganization(value: Organization) {
this.organization = value;
this.changeOrganization();
}
@Output() onEventsClicked = new EventEmitter<CipherView>();
protected allCiphers: CipherView[] = [];
constructor(
searchService: SearchService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
cipherService: CipherService,
vaultFilterService: VaultFilterService,
eventCollectionService: EventCollectionService,
totpService: TotpService,
passwordRepromptService: PasswordRepromptService,
dialogService: DialogService,
logService: LogService,
stateService: StateService,
organizationService: OrganizationService,
tokenService: TokenService,
searchPipe: SearchPipe,
protected groupService: GroupService,
private apiService: ApiService
) {
super(
searchService,
i18nService,
platformUtilsService,
vaultFilterService,
cipherService,
eventCollectionService,
totpService,
stateService,
passwordRepromptService,
dialogService,
logService,
searchPipe,
organizationService,
tokenService
);
}
ngOnDestroy() {
super.ngOnDestroy();
}
async changeOrganization() {
this.groups = await this.groupService.getAll(this.organization?.id);
await this.loadCiphers();
await this.reload(this.activeFilter.buildFilter());
}
async loadCiphers() {
if (this.organization?.canEditAnyCollection) {
this.accessEvents = this.organization?.useEvents;
this.allCiphers = await this.cipherService.getAllFromApiForOrganization(
this.organization?.id
);
} else {
this.allCiphers = (await this.cipherService.getAllDecrypted()).filter(
(c) => c.organizationId === this.organization?.id
);
}
this.searchService.indexCiphers(this.allCiphers, this.organization?.id);
}
async refreshCollections(): Promise<void> {
await this.vaultFilterService.reloadCollections();
if (this.activeFilter.selectedCollectionNode) {
this.activeFilter.selectedCollectionNode =
await this.vaultFilterService.getCollectionNodeFromTree(
this.activeFilter.selectedCollectionNode.node.id
);
}
}
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
this.deleted = deleted ?? false;
await this.applyFilter(filter);
this.loaded = true;
}
async refresh() {
await this.loadCiphers();
await this.refreshCollections();
super.refresh();
}
async search(timeout: number = null) {
await super.search(timeout, this.allCiphers);
}
events(c: CipherView) {
this.onEventsClicked.emit(c);
}
protected showFixOldAttachments(c: CipherView) {
return this.organization?.canEditAnyCollection && c.hasOldAttachments;
}
checkAll(select: boolean) {
if (select) {
this.checkAll(false);
}
const items: VaultItemRow[] = [...this.collections, ...this.ciphers];
if (!items.length) {
return;
}
const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length;
for (let i = 0; i < selectCount; i++) {
this.checkRow(items[i], select);
}
}
checkRow(item: VaultItemRow, select?: boolean) {
if (item instanceof TreeNode && item.node.id == null) {
return;
}
// Do not allow checking a collection we cannot delete
if (item instanceof TreeNode && !this.canDeleteCollection(item.node)) {
return;
}
item.checked = select ?? !item.checked;
}
get selectedCollections(): TreeNode<CollectionFilter>[] {
if (!this.collections) {
return [];
}
return this.collections.filter((c) => !!(c as VaultItemRow).checked);
}
get selectedCollectionIds(): string[] {
return this.selectedCollections.map((c) => c.node.id);
}
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)
);
}
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.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
get showMissingCollectionPermissionMessage(): boolean {
// Not filtering by collections, so no need to show message
if (this.activeFilter.selectedCollectionNode == null) {
return false;
}
// Filtering by all collections, so no need to show message
if (this.activeFilter.selectedCollectionNode.node.id == "AllCollections") {
return false;
}
// Filtering by a collection, so show message if user is not assigned
return !this.activeFilter.selectedCollectionNode.node.assigned && !this.organization.isAdmin;
}
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.organization.canDeleteAnyCollection
) {
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.actionPromise = null;
await this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkDelete() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
const selectedCollectionIds = this.deleted ? null : this.selectedCollectionIds;
if (!selectedCipherIds?.length && !selectedCollectionIds?.length) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.deleted,
cipherIds: selectedCipherIds,
collectionIds: selectedCollectionIds,
organization: this.organization,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
/**
* @deprecated Block interaction using long running modal dialog instead
*/
protected get isProcessingAction() {
return this.actionPromise != null;
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
if (!this.organization?.canEditAnyCollection) {
return super.deleteCipherWithServer(id, this.deleted);
}
return permanent
? this.apiService.deleteCipherAdmin(id)
: this.apiService.putDeleteCipherAdmin(id);
}
}

View File

@ -18,11 +18,14 @@
</div>
<div class="col-9">
<app-org-vault-header
[activeFilter]="activeFilter"
(onCollectionChanged)="refreshItems()"
[actionPromise]="vaultItemsComponent.actionPromise"
(onAddCipher)="addCipher()"
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
(onAddCipher)="addCipher()"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
></app-org-vault-header>
<app-callout
type="warning"
@ -31,21 +34,68 @@
>
{{ trashCleanupWarning }}
</app-callout>
<app-org-vault-items
[activeFilter]="activeFilter"
[initOrganization]="organization"
(onCipherClicked)="navigateToCipher($event)"
(onAttachmentsClicked)="editCipherAttachments($event)"
(onAddCipher)="addCipher()"
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
(onEventsClicked)="viewEvents($event)"
(onCloneClicked)="cloneCipher($event)"
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups"
[disabled]="loading"
[showOwner]="false"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.useEvents"
[editableCollections]="true"
[cloneableOrganizationCiphers]="true"
(onEvent)="onVaultItemsEvent($event)"
>
</app-org-vault-items>
</app-vault-items>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="showMissingCollectionPermissionMessage"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noPermissionToViewAllCollectionItems" | i18n }}</p>
</div>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="isEmpty && !showMissingCollectionPermissionMessage && !performingInitialLoad"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</div>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
</div>
</div>
<ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></ng-template>
<ng-template #collectionsModal></ng-template>
<ng-template #eventsTemplate></ng-template>
</div>
<ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></ng-template>
<ng-template #collections></ng-template>
<ng-template #eventsTemplate></ng-template>

View File

@ -8,35 +8,85 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { combineLatest, firstValueFrom, Subject } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators";
import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs";
import {
concatMap,
debounceTime,
distinctUntilChanged,
filter,
first,
map,
shareReplay,
switchMap,
takeUntil,
tap,
} from "rxjs/operators";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { EventType } from "@bitwarden/common/enums";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { Utils } from "@bitwarden/common/misc/utils";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { DialogService, Icons } from "@bitwarden/components";
import {
CollectionAdminService,
CollectionAdminView,
GroupService,
GroupView,
} from "../../admin-console/organizations/core";
import { EntityEventsComponent } from "../../admin-console/organizations/manage/entity-events.component";
import {
CollectionDialogResult,
CollectionDialogTabType,
openCollectionDialog,
} from "../../admin-console/organizations/shared";
import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "../individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import {
BulkRestoreDialogResult,
openBulkRestoreDialog,
} from "../individual-vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component";
import { RoutedVaultFilterBridgeService } from "../individual-vault/vault-filter/services/routed-vault-filter-bridge.service";
import { RoutedVaultFilterService } from "../individual-vault/vault-filter/services/routed-vault-filter.service";
import { createFilterFunction } from "../individual-vault/vault-filter/shared/models/filter-function";
import {
All,
RoutedVaultFilterModel,
Unassigned,
} from "../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { getNestedCollectionTree } from "../utils/collection-utils";
import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component";
import { CollectionsComponent } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
import { VaultItemsComponent } from "./vault-items.component";
const BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200;
@Component({
selector: "app-org-vault",
@ -44,21 +94,38 @@ const BroadcasterSubscriptionId = "OrgVaultComponent";
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
})
export class VaultComponent implements OnInit, OnDestroy {
protected Unassigned = Unassigned;
@ViewChild("vaultFilter", { static: true })
vaultFilterComponent: VaultFilterComponent;
@ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent;
@ViewChild("attachments", { read: ViewContainerRef, static: true })
attachmentsModalRef: ViewContainerRef;
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
cipherAddEditModalRef: ViewContainerRef;
@ViewChild("collections", { read: ViewContainerRef, static: true })
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
collectionsModalRef: ViewContainerRef;
@ViewChild("eventsTemplate", { read: ViewContainerRef, static: true })
eventsModalRef: ViewContainerRef;
organization: Organization;
trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter();
protected noItemIcon = Icons.Search;
protected performingInitialLoad = true;
protected refreshing = false;
protected processingEvent = false;
protected filter: RoutedVaultFilterModel = {};
protected organization: Organization;
protected allCollections: CollectionAdminView[];
protected allGroups: GroupView[];
protected ciphers: CipherView[];
protected collections: CollectionAdminView[];
protected selectedCollection: TreeNode<CollectionAdminView> | undefined;
protected isEmpty: boolean;
protected showMissingCollectionPermissionMessage: boolean;
private refresh$ = new BehaviorSubject<void>(null);
private searchText$ = new Subject<string>();
private destroy$ = new Subject<void>();
constructor(
@ -66,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService,
protected vaultFilterService: VaultFilterService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private routedVaultFilterService: RoutedVaultFilterService,
private router: Router,
private changeDetectorRef: ChangeDetectorRef,
private syncService: SyncService,
@ -77,7 +145,15 @@ export class VaultComponent implements OnInit, OnDestroy {
private ngZone: NgZone,
private platformUtilsService: PlatformUtilsService,
private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService
private passwordRepromptService: PasswordRepromptService,
private collectionAdminService: CollectionAdminService,
private searchService: SearchService,
private searchPipe: SearchPipe,
private groupService: GroupService,
private logService: LogService,
private eventCollectionService: EventCollectionService,
private totpService: TotpService,
private apiService: ApiService
) {}
async ngOnInit() {
@ -87,25 +163,203 @@ export class VaultComponent implements OnInit, OnDestroy {
: "trashCleanupWarning"
);
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.organization = this.organizationService.get(params.organizationId);
const filter$ = this.routedVaultFilterService.filter$;
const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId),
filter((filter) => filter !== undefined),
distinctUntilChanged()
);
const organization$ = organizationId$.pipe(
switchMap((organizationId) => this.organizationService.get$(organizationId)),
takeUntil(this.destroy$),
shareReplay({ refCount: false, bufferSize: 1 })
);
const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe(
first(),
switchMap(async ([organization]) => {
this.organization = organization;
if (!organization.canUseAdminCollections) {
await this.syncService.fullSync(false);
}
return undefined;
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
this.refresh();
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => {
this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search;
});
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => {
this.activeFilter = activeFilter;
});
// verifies that the organization has been set
combineLatest([this.route.queryParams, this.route.parent.params])
this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$))
.subscribe((searchText) =>
this.router.navigate([], {
queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText },
queryParamsHandling: "merge",
replaceUrl: true,
})
);
const querySearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const allCollectionsWithoutUnassigned$ = organizationId$.pipe(
switchMap((orgId) => this.collectionAdminService.getAll(orgId)),
shareReplay({ refCount: true, bufferSize: 1 })
);
const allCollections$ = combineLatest([organizationId$, allCollectionsWithoutUnassigned$]).pipe(
map(([organizationId, allCollections]) => {
const noneCollection = new CollectionAdminView();
noneCollection.name = this.i18nService.t("unassigned");
noneCollection.id = Unassigned;
noneCollection.organizationId = organizationId;
return allCollections.concat(noneCollection);
})
);
const allGroups$ = organizationId$.pipe(
switchMap((organizationId) => this.groupService.getAll(organizationId)),
shareReplay({ refCount: true, bufferSize: 1 })
);
const allCiphers$ = organization$.pipe(
concatMap(async (organization) => {
let ciphers;
if (organization.canEditAnyCollection) {
ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
} else {
ciphers = (await this.cipherService.getAllDecrypted()).filter(
(c) => c.organizationId === organization.id
);
}
await this.searchService.indexCiphers(ciphers, organization.id);
return ciphers;
})
);
const ciphers$ = combineLatest([allCiphers$, filter$, querySearchText$]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
if (filter.collectionId === undefined && filter.type === undefined) {
return [];
}
const filterFunction = createFilterFunction(filter);
if (this.searchService.isSearchable(searchText)) {
return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers);
}
return ciphers.filter(filterFunction);
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
shareReplay({ refCount: true, bufferSize: 1 })
);
const collections$ = combineLatest([nestedCollections$, filter$, querySearchText$]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined),
map(([collections, filter, searchText]) => {
if (
filter.collectionId === Unassigned ||
(filter.collectionId === undefined && filter.type !== undefined)
) {
return [];
}
let collectionsToReturn = [];
if (filter.collectionId === undefined || filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
}
if (this.searchService.isSearchable(searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,
(collection) => collection.name,
(collection) => collection.id
);
}
return collectionsToReturn;
}),
takeUntil(this.destroy$),
shareReplay({ refCount: true, bufferSize: 1 })
);
const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined),
map(([collections, filter]) => {
if (
filter.collectionId === undefined ||
filter.collectionId === All ||
filter.collectionId === Unassigned
) {
return undefined;
}
return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId);
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
const showMissingCollectionPermissionMessage$ = combineLatest([
filter$,
selectedCollection$,
organization$,
]).pipe(
map(([filter, collection, organization]) => {
return (
// Filtering by unassigned, show message if not admin
(filter.collectionId === Unassigned && !organization.canUseAdminCollections) ||
// Filtering by a collection, so show message if user is not assigned
(collection != undefined &&
!collection.node.assigned &&
!organization.canUseAdminCollections)
);
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
firstSetup$
.pipe(
switchMap(async ([qParams]) => {
switchMap(() => combineLatest([this.route.queryParams, organization$])),
switchMap(async ([qParams, organization]) => {
const cipherId = getCipherIdFromParams(qParams);
if (!cipherId) {
return;
}
if (
// Handle users with implicit collection access since they use the admin endpoint
this.organization.canUseAdminCollections ||
organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null
) {
this.editCipherId(cipherId);
@ -125,30 +379,58 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.subscribe();
if (!this.organization.canUseAdminCollections) {
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await Promise.all([
this.vaultFilterService.reloadCollections(),
this.vaultItemsComponent.refresh(),
]);
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
await this.syncService.fullSync(false);
}
firstSetup$
.pipe(
switchMap(() => this.refresh$),
tap(() => (this.refreshing = true)),
switchMap(() =>
combineLatest([
organization$,
filter$,
allCollections$,
allGroups$,
ciphers$,
collections$,
selectedCollection$,
showMissingCollectionPermissionMessage$,
])
),
takeUntil(this.destroy$)
)
.subscribe(
([
organization,
filter,
allCollections,
allGroups,
ciphers,
collections,
selectedCollection,
showMissingCollectionPermissionMessage,
]) => {
this.organization = organization;
this.filter = filter;
this.allCollections = allCollections;
this.allGroups = allGroups;
this.ciphers = ciphers;
this.collections = collections;
this.selectedCollection = selectedCollection;
this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage;
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => {
this.activeFilter = activeFilter;
});
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
// This is a temporary fix to avoid double fetching collections.
// TODO: Remove when implementing new VVR menu
this.vaultFilterService.reloadCollections(allCollections);
this.refreshing = false;
this.performingInitialLoad = false;
}
);
}
get loading() {
return this.refreshing || this.processingEvent;
}
ngOnDestroy() {
@ -157,15 +439,50 @@ export class VaultComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async refreshItems() {
this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh();
await this.vaultItemsComponent.actionPromise;
this.vaultItemsComponent.actionPromise = null;
async onVaultItemsEvent(event: VaultItemEvent) {
this.processingEvent = true;
try {
if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") {
await this.editCipherCollections(event.item);
} else if (event.type === "clone") {
await this.cloneCipher(event.item);
} else if (event.type === "restore") {
if (event.items.length === 1) {
await this.restore(event.items[0]);
} else {
await this.bulkRestore(event.items);
}
} else if (event.type === "delete") {
const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher);
const collections = event.items
.filter((i) => i.cipher === undefined)
.map((i) => i.collection);
if (ciphers.length === 1 && collections.length === 0) {
await this.deleteCipher(ciphers[0]);
} else if (ciphers.length === 0 && collections.length === 1) {
await this.deleteCollection(collections[0]);
} else {
await this.bulkDelete(ciphers, collections, this.organization);
}
} else if (event.type === "copyField") {
await this.copy(event.item, event.field);
} else if (event.type === "edit") {
await this.editCollection(event.item, CollectionDialogTabType.Info);
} else if (event.type === "viewAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access);
} else if (event.type === "viewEvents") {
await this.viewEvents(event.item);
}
} finally {
this.processingEvent = false;
}
}
filterSearchText(searchText: string) {
this.vaultItemsComponent.searchText = searchText;
this.vaultItemsComponent.search(200);
this.searchText$.next(searchText);
}
async editCipherAttachments(cipher: CipherView) {
@ -182,17 +499,18 @@ export class VaultComponent implements OnInit, OnDestroy {
(comp) => {
comp.organization = this.organization;
comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true));
comp.onUploadedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
comp.onDeletedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
}
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
modal.onClosed.subscribe(async () => {
modal.onClosed.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (madeAttachmentChanges) {
await this.vaultItemsComponent.refresh();
this.refresh();
}
madeAttachmentChanges = false;
});
@ -208,10 +526,9 @@ export class VaultComponent implements OnInit, OnDestroy {
comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null);
comp.organization = this.organization;
comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedCollections.subscribe(async () => {
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
}
);
@ -258,20 +575,17 @@ export class VaultComponent implements OnInit, OnDestroy {
comp.organization = this.organization;
comp.organizationId = this.organization.id;
comp.cipherId = cipherId;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onSavedCipher.subscribe(async () => {
comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onDeletedCipher.subscribe(async () => {
comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onRestoredCipher.subscribe(async () => {
comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
await this.vaultItemsComponent.refresh();
this.refresh();
});
};
@ -305,6 +619,241 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
async restore(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
return;
}
if (!c.isDeleted) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("restoreItemConfirmation"),
this.i18nService.t("restoreItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.cipherService.restoreWithServer(c.id);
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem"));
this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkRestore(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
const selectedCipherIds = ciphers.map((cipher) => cipher.id);
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkRestoreDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkRestoreDialogResult.Restored) {
this.refresh();
}
}
async deleteCipher(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
return;
}
const permanent = c.isDeleted;
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t(
permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation"
),
this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.deleteCipherWithServer(c.id, permanent);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem")
);
this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async deleteCollection(collection: CollectionView): Promise<void> {
if (
!this.organization.canDeleteAssignedCollections &&
!this.organization.canDeleteAnyCollection
) {
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 {
await this.apiService.deleteCollection(this.organization?.id, collection.id);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", collection.name)
);
// Navigate away if we deleted the colletion we were viewing
if (this.selectedCollection?.node.id === collection.id) {
this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});
}
this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkDelete(
ciphers: CipherView[],
collections: CollectionView[],
organization: Organization
) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
if (ciphers.length === 0 && collections.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.filter.type === "trash",
cipherIds: ciphers.map((c) => c.id),
collectionIds: collections.map((c) => c.id),
organization,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.refresh();
}
}
async copy(cipher: CipherView, field: "username" | "password" | "totp") {
let aType;
let value;
let typeI18nKey;
if (field === "username") {
aType = "Username";
value = cipher.login.username;
typeI18nKey = "username";
} else if (field === "password") {
aType = "Password";
value = cipher.login.password;
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
value = await this.totpService.getCode(cipher.login.totp);
typeI18nKey = "verificationCodeTotp";
} else {
this.platformUtilsService.showToast("info", null, this.i18nService.t("unexpectedError"));
return;
}
if (
this.passwordRepromptService.protectedFields().includes(aType) &&
!(await this.repromptCipher([cipher]))
) {
return;
}
if (!cipher.viewPassword) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey))
);
if (field === "password" || field === "totp") {
this.eventCollectionService.collect(
EventType.Cipher_ClientToggledHiddenFieldVisible,
cipher.id
);
}
}
async addCollection(): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, {
data: {
organizationId: this.organization?.id,
parentCollectionId: this.selectedCollection?.node.id,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.refresh();
}
}
async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tab },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.refresh();
}
}
async viewEvents(cipher: CipherView) {
await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => {
comp.name = cipher.name;
@ -315,6 +864,22 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id)
: this.cipherService.softDeleteWithServer(id);
}
protected async repromptCipher(ciphers: CipherView[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
private refresh() {
this.refresh$.next();
}
private go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {

View File

@ -6,12 +6,12 @@ import { LooseComponentsModule } from "../../shared/loose-components.module";
import { SharedModule } from "../../shared/shared.module";
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
import { VaultItemsComponent } from "./vault-items.component";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
@ -26,8 +26,9 @@ import { VaultComponent } from "./vault.component";
OrganizationBadgeModule,
PipesModule,
BreadcrumbsModule,
VaultItemsModule,
],
declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent],
})
export class VaultModule {}

View File

@ -0,0 +1,56 @@
import {
CollectionView,
NestingDelimiter,
} from "@bitwarden/common/admin-console/models/view/collection.view";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CollectionAdminView } from "../../admin-console/organizations/core";
export function getNestedCollectionTree(
collections: CollectionAdminView[]
): TreeNode<CollectionAdminView>[];
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
export function getNestedCollectionTree(
collections: (CollectionView | CollectionAdminView)[]
): TreeNode<CollectionView | CollectionAdminView>[] {
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections = collections.map(cloneCollection);
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
clonedCollections.forEach((collection) => {
const parts =
collection.name != null
? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter)
: [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collection, null, NestingDelimiter);
});
return nodes;
}
function cloneCollection(collection: CollectionView): CollectionView;
function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
function cloneCollection(
collection: CollectionView | CollectionAdminView
): CollectionView | CollectionAdminView {
let cloned;
if (collection instanceof CollectionAdminView) {
cloned = new CollectionAdminView();
cloned.groups = [...collection.groups];
cloned.users = [...collection.users];
cloned.assigned = collection.assigned;
} else {
cloned = new CollectionView();
}
cloned.id = collection.id;
cloned.externalId = collection.externalId;
cloned.hidePasswords = collection.hidePasswords;
cloned.name = collection.name;
cloned.organizationId = collection.organizationId;
cloned.readOnly = collection.readOnly;
return cloned;
}

View File

@ -3,6 +3,8 @@ import { View } from "../../../models/view/view";
import { Collection } from "../domain/collection";
import { CollectionAccessDetailsResponse } from "../response/collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
id: string = null;
organizationId: string = null;

View File

@ -94,14 +94,14 @@ export class ServiceUtils {
/**
* Searches an array of tree nodes for a node with a matching `id`
* @param {TreeNode<ITreeNodeObject>} nodeTree - An array of TreeNode branches that will be searched
* @param {TreeNode<T>} nodeTree - An array of TreeNode branches that will be searched
* @param {string} id - The id of the node to be found
* @returns {TreeNode<ITreeNodeObject>} The node with a matching `id`
* @returns {TreeNode<T>} The node with a matching `id`
*/
static getTreeNodeObjectFromList(
nodeTree: TreeNode<ITreeNodeObject>[],
static getTreeNodeObjectFromList<T extends ITreeNodeObject>(
nodeTree: TreeNode<T>[],
id: string
): TreeNode<ITreeNodeObject> {
): TreeNode<T> {
for (let i = 0; i < nodeTree.length; i++) {
if (nodeTree[i].node.id === id) {
return nodeTree[i];

View File

@ -1,6 +1,7 @@
/* eslint-disable no-useless-escape */
import * as path from "path";
import { Observable, of, switchMap } from "rxjs";
import { getHostname, parse } from "tldts";
import { Merge } from "type-fest";
@ -526,6 +527,17 @@ export class Utils {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Generate an observable from a function that returns a promise.
* Similar to the rxjs function {@link from} with one big exception:
* {@link from} will not re-execute the function when observers resubscribe.
* {@link Util.asyncToObservable} will execute `generator` for every
* subscribe, making it ideal if the value ever needs to be refreshed.
* */
static asyncToObservable<T>(generator: () => Promise<T>): Observable<T> {
return of(undefined).pipe(switchMap(() => generator()));
}
private static isAppleMobile(win: Window) {
return (
win.navigator.userAgent.match(/iPhone/i) != null ||

View File

@ -1,4 +1,5 @@
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
import { QueryParamsHandling } from "@angular/router";
@Component({
selector: "bit-breadcrumb",
@ -14,6 +15,9 @@ export class BreadcrumbComponent {
@Input()
queryParams?: Record<string, string> = {};
@Input()
queryParamsHandling?: QueryParamsHandling;
@Output()
click = new EventEmitter();

View File

@ -6,6 +6,7 @@
class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
[queryParamsHandling]="breadcrumb.queryParamsHandling"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
@ -43,6 +44,7 @@
linkType="primary"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
[queryParamsHandling]="breadcrumb.queryParamsHandling"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
@ -64,6 +66,7 @@
class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
[queryParamsHandling]="breadcrumb.queryParamsHandling"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>

View File

@ -1,6 +1,6 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<table class="tw-w-full tw-table-auto tw-leading-normal tw-text-main">
<table [ngClass]="tableClass">
<thead
class="tw-text-bold tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-text-muted"
>

View File

@ -26,6 +26,7 @@ export class TableBodyDirective {
})
export class TableComponent implements OnDestroy, AfterContentChecked {
@Input() dataSource: TableDataSource<any>;
@Input() layout: "auto" | "fixed" = "auto";
@ContentChild(TableBodyDirective) templateVariable: TableBodyDirective;
@ -33,6 +34,15 @@ export class TableComponent implements OnDestroy, AfterContentChecked {
private _initialized = false;
get tableClass() {
return [
"tw-w-full",
"tw-leading-normal",
"tw-text-main",
this.layout === "auto" ? "tw-table-auto" : "tw-table-fixed",
];
}
ngAfterContentChecked(): void {
if (!this._initialized && isDataSource(this.dataSource)) {
this._initialized = true;