[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:
parent
5f26e58538
commit
0bc6add5c3
|
@ -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>
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 }] });
|
||||
}
|
||||
}
|
|
@ -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[] };
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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$);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue