[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 { NgModule } from "@angular/core";
|
||||||
|
|
||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared/shared.module";
|
||||||
|
|
||||||
import { OrganizationNameBadgeComponent } from "./organization-name-badge.component";
|
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
|
<button
|
||||||
bitBadge
|
bitBadge
|
||||||
|
type="button"
|
||||||
|
[disabled]="disabled"
|
||||||
[style.color]="textColor"
|
[style.color]="textColor"
|
||||||
[style.background-color]="color"
|
[style.background-color]="color"
|
||||||
appA11yTitle="{{ organizationName }}"
|
appA11yTitle="{{ organizationName }}"
|
||||||
(click)="emitOnOrganizationClicked()"
|
routerLink
|
||||||
|
[queryParams]="{ organizationId: organizationIdLink }"
|
||||||
|
queryParamsHandling="merge"
|
||||||
>
|
>
|
||||||
{{ organizationName | ellipsis : 13 }}
|
{{ name | ellipsis : 13 }}
|
||||||
</button>
|
</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 { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service";
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||||
import { Utils } from "@bitwarden/common/misc/utils";
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
|
|
||||||
|
import { Unassigned } from "../vault-filter/shared/models/routed-vault-filter.model";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-org-badge",
|
selector: "app-org-badge",
|
||||||
templateUrl: "organization-name-badge.component.html",
|
templateUrl: "organization-name-badge.component.html",
|
||||||
})
|
})
|
||||||
export class OrganizationNameBadgeComponent implements OnInit {
|
export class OrganizationNameBadgeComponent implements OnChanges {
|
||||||
|
@Input() organizationId?: string;
|
||||||
@Input() organizationName: string;
|
@Input() organizationName: string;
|
||||||
@Input() profileName: string;
|
@Input() disabled: boolean;
|
||||||
|
|
||||||
@Output() onOrganizationClicked = new EventEmitter<string>();
|
|
||||||
|
|
||||||
|
// Need a separate variable or we get weird behavior when used as part of cdk virtual scrolling
|
||||||
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
textColor: string;
|
textColor: string;
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
|
@ -25,12 +28,13 @@ export class OrganizationNameBadgeComponent implements OnInit {
|
||||||
private tokenService: TokenService
|
private tokenService: TokenService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
// ngOnChanges is required since this component might be reused as part of
|
||||||
if (this.organizationName == null || this.organizationName === "") {
|
// cdk virtual scrolling
|
||||||
this.organizationName = this.i18nService.t("me");
|
async ngOnChanges() {
|
||||||
this.isMe = true;
|
this.isMe = this.organizationName == null || this.organizationName === "";
|
||||||
}
|
|
||||||
if (this.isMe) {
|
if (this.isMe) {
|
||||||
|
this.name = this.i18nService.t("me");
|
||||||
this.color = await this.avatarService.loadColorFromState();
|
this.color = await this.avatarService.loadColorFromState();
|
||||||
if (this.color == null) {
|
if (this.color == null) {
|
||||||
const userId = await this.tokenService.getUserId();
|
const userId = await this.tokenService.getUserId();
|
||||||
|
@ -43,12 +47,13 @@ export class OrganizationNameBadgeComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.name = this.organizationName;
|
||||||
this.color = Utils.stringToColor(this.organizationName.toUpperCase());
|
this.color = Utils.stringToColor(this.organizationName.toUpperCase());
|
||||||
}
|
}
|
||||||
this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important";
|
this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important";
|
||||||
}
|
}
|
||||||
|
|
||||||
emitOnOrganizationClicked() {
|
get organizationIdLink() {
|
||||||
this.onOrganizationClicked.emit();
|
return this.organizationId ?? Unassigned;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||||
})
|
})
|
||||||
export class GetOrgNameFromIdPipe implements PipeTransform {
|
export class GetOrgNameFromIdPipe implements PipeTransform {
|
||||||
transform(value: string, organizations: Organization[]) {
|
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;
|
return orgName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ export abstract class VaultFilterService {
|
||||||
folderTree$: Observable<TreeNode<FolderFilter>>;
|
folderTree$: Observable<TreeNode<FolderFilter>>;
|
||||||
collectionTree$: Observable<TreeNode<CollectionFilter>>;
|
collectionTree$: Observable<TreeNode<CollectionFilter>>;
|
||||||
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
|
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
|
||||||
reloadCollections: () => Promise<void>;
|
reloadCollections: (collections: CollectionView[]) => void;
|
||||||
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
|
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
|
||||||
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
|
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
|
||||||
expandOrgFilter: () => Promise<void>;
|
expandOrgFilter: () => Promise<void>;
|
||||||
|
|
|
@ -151,7 +151,12 @@ function createLegacyFilterForEndUser(
|
||||||
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId);
|
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(
|
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
|
||||||
organizationTree,
|
organizationTree,
|
||||||
filter.organizationId
|
filter.organizationId
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { firstValueFrom, ReplaySubject, take } from "rxjs";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
import { StateService } from "@bitwarden/common/abstractions/state.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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
@ -23,7 +22,6 @@ describe("vault filter service", () => {
|
||||||
let organizationService: MockProxy<OrganizationService>;
|
let organizationService: MockProxy<OrganizationService>;
|
||||||
let folderService: MockProxy<FolderService>;
|
let folderService: MockProxy<FolderService>;
|
||||||
let cipherService: MockProxy<CipherService>;
|
let cipherService: MockProxy<CipherService>;
|
||||||
let collectionService: MockProxy<CollectionService>;
|
|
||||||
let policyService: MockProxy<PolicyService>;
|
let policyService: MockProxy<PolicyService>;
|
||||||
let i18nService: MockProxy<I18nService>;
|
let i18nService: MockProxy<I18nService>;
|
||||||
let organizations: ReplaySubject<Organization[]>;
|
let organizations: ReplaySubject<Organization[]>;
|
||||||
|
@ -34,7 +32,6 @@ describe("vault filter service", () => {
|
||||||
organizationService = mock<OrganizationService>();
|
organizationService = mock<OrganizationService>();
|
||||||
folderService = mock<FolderService>();
|
folderService = mock<FolderService>();
|
||||||
cipherService = mock<CipherService>();
|
cipherService = mock<CipherService>();
|
||||||
collectionService = mock<CollectionService>();
|
|
||||||
policyService = mock<PolicyService>();
|
policyService = mock<PolicyService>();
|
||||||
i18nService = mock<I18nService>();
|
i18nService = mock<I18nService>();
|
||||||
i18nService.collator = new Intl.Collator("en-US");
|
i18nService.collator = new Intl.Collator("en-US");
|
||||||
|
@ -50,7 +47,6 @@ describe("vault filter service", () => {
|
||||||
organizationService,
|
organizationService,
|
||||||
folderService,
|
folderService,
|
||||||
cipherService,
|
cipherService,
|
||||||
collectionService,
|
|
||||||
policyService,
|
policyService,
|
||||||
i18nService
|
i18nService
|
||||||
);
|
);
|
||||||
|
@ -177,8 +173,7 @@ describe("vault filter service", () => {
|
||||||
createCollectionView("1", "collection 1", "org test id"),
|
createCollectionView("1", "collection 1", "org test id"),
|
||||||
createCollectionView("2", "collection 2", "non matching org id"),
|
createCollectionView("2", "collection 2", "non matching org id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
vaultFilterService.reloadCollections(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
|
||||||
|
|
||||||
await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([
|
await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([
|
||||||
createCollectionView("1", "collection 1", "org test id"),
|
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-2", "Collection 1/Collection 2", "org test id"),
|
||||||
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
|
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
vaultFilterService.reloadCollections(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
|
||||||
|
|
||||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||||
|
|
||||||
|
@ -207,8 +201,7 @@ describe("vault filter service", () => {
|
||||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||||
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
vaultFilterService.reloadCollections(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
|
||||||
|
|
||||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
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-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||||
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
|
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
vaultFilterService.reloadCollections(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
|
||||||
|
|
||||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||||
|
|
||||||
|
@ -243,8 +235,7 @@ describe("vault filter service", () => {
|
||||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||||
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
vaultFilterService.reloadCollections(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
|
||||||
|
|
||||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
map,
|
map,
|
||||||
|
@ -12,7 +13,6 @@ import {
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||||
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
|
|
||||||
import {
|
import {
|
||||||
isNotProviderUser,
|
isNotProviderUser,
|
||||||
OrganizationService,
|
OrganizationService,
|
||||||
|
@ -67,8 +67,10 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
||||||
// TODO: Remove once collections is refactored with observables
|
// TODO: Remove once collections is refactored with observables
|
||||||
// replace with collection service observable
|
// replace with collection service observable
|
||||||
private collectionViews$ = new ReplaySubject<CollectionView[]>(1);
|
private collectionViews$ = new ReplaySubject<CollectionView[]>(1);
|
||||||
filteredCollections$: Observable<CollectionView[]> = this.collectionViews$.pipe(
|
filteredCollections$: Observable<CollectionView[]> = combineLatest([
|
||||||
combineLatestWith(this._organizationFilter),
|
this.collectionViews$,
|
||||||
|
this._organizationFilter,
|
||||||
|
]).pipe(
|
||||||
switchMap(([collections, org]) => {
|
switchMap(([collections, org]) => {
|
||||||
return this.filterCollections(collections, org);
|
return this.filterCollections(collections, org);
|
||||||
})
|
})
|
||||||
|
@ -84,14 +86,12 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
||||||
protected organizationService: OrganizationService,
|
protected organizationService: OrganizationService,
|
||||||
protected folderService: FolderService,
|
protected folderService: FolderService,
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected collectionService: CollectionService,
|
|
||||||
protected policyService: PolicyService,
|
protected policyService: PolicyService,
|
||||||
protected i18nService: I18nService
|
protected i18nService: I18nService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// TODO: Remove once collections is refactored with observables
|
async reloadCollections(collections: CollectionView[]) {
|
||||||
async reloadCollections() {
|
this.collectionViews$.next(collections);
|
||||||
this.collectionViews$.next(await this.collectionService.getAllDecrypted());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCollectionNodeFromTree(id: string) {
|
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>) {
|
set selectedOrganizationNode(value: TreeNode<OrganizationFilter>) {
|
||||||
this.bridgeService.navigate({
|
this.bridgeService.navigate({
|
||||||
...this.routedFilter,
|
...this.routedFilter,
|
||||||
organizationId: value.node.id,
|
organizationId: value?.node.id === "MyVault" ? Unassigned : value?.node.id,
|
||||||
folderId: undefined,
|
folderId: undefined,
|
||||||
collectionId: undefined,
|
collectionId: undefined,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,40 +1,45 @@
|
||||||
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
|
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
|
||||||
<div>
|
<div>
|
||||||
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
|
<bit-breadcrumbs *ngIf="showBreadcrumbs">
|
||||||
<bit-breadcrumb
|
<bit-breadcrumb
|
||||||
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
|
*ngIf="activeOrganizationId"
|
||||||
[icon]="first ? undefined : 'bwi-collection'"
|
[route]="[]"
|
||||||
(click)="applyCollectionFilter(collection)"
|
[queryParams]="{ organizationId: activeOrganizationId, collectionId: All }"
|
||||||
|
queryParamsHandling="merge"
|
||||||
>
|
>
|
||||||
<!-- First node in the tree is the "Org Name Vault" item. The rest come from user input. -->
|
{{ activeOrganizationId | orgNameFromId : organizations }} {{ "vault" | i18n | lowercase }}
|
||||||
<ng-container *ngIf="first">
|
|
||||||
{{ activeOrganizationId | orgNameFromId : (organizations$ | async) }}
|
|
||||||
{{ "vault" | i18n | lowercase }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
|
|
||||||
</bit-breadcrumb>
|
</bit-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>
|
</bit-breadcrumbs>
|
||||||
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
|
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
|
||||||
<i
|
<i
|
||||||
*ngIf="activeFilter.isCollectionSelected"
|
*ngIf="filter.collectionId && filter.collectionId !== All"
|
||||||
class="bwi bwi-collection"
|
class="bwi bwi-collection"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i>
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<small #actionSpinner [appApiAction]="actionPromise">
|
<small *ngIf="loading">
|
||||||
<ng-container *ngIf="$any(actionSpinner).loading">
|
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-spinner bwi-spin text-muted"
|
class="bwi bwi-spinner bwi-spin text-muted"
|
||||||
title="{{ 'loading' | i18n }}"
|
title="{{ 'loading' | i18n }}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i>
|
||||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
</ng-container>
|
|
||||||
</small>
|
</small>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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()">
|
<button type="button" bitButton buttonType="primary" (click)="addCipher()">
|
||||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||||
{{ "newItem" | i18n }}
|
{{ "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 { 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 { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
||||||
|
|
||||||
import { VaultFilter } from "../vault-filter/shared/models/vault-filter.model";
|
import {
|
||||||
import { CollectionFilter } from "../vault-filter/shared/models/vault-filter.type";
|
All,
|
||||||
|
RoutedVaultFilterModel,
|
||||||
|
Unassigned,
|
||||||
|
} from "../vault-filter/shared/models/routed-vault-filter.model";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-vault-header",
|
selector: "app-vault-header",
|
||||||
templateUrl: "./vault-header.component.html",
|
templateUrl: "./vault-header.component.html",
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class VaultHeaderComponent {
|
export class VaultHeaderComponent {
|
||||||
/**
|
protected Unassigned = Unassigned;
|
||||||
* Promise that is used to determine the loading state of the header via the ApiAction directive.
|
protected All = All;
|
||||||
* When the promise exists and is not resolved, the loading spinner will be shown.
|
|
||||||
*/
|
|
||||||
@Input() actionPromise: Promise<any>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The filter being actively applied to the vault view
|
* 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
|
* Emits an event when the new item button is clicked in the header
|
||||||
*/
|
*/
|
||||||
@Output() onAddCipher = new EventEmitter<void>();
|
@Output() onAddCipher = new EventEmitter<void>();
|
||||||
|
|
||||||
organizations$ = this.organizationService.organizations$;
|
constructor(private i18nService: I18nService) {}
|
||||||
|
|
||||||
constructor(private organizationService: OrganizationService, private i18nService: I18nService) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id of the organization that is currently being filtered on.
|
* The id of the organization that is currently being filtered on.
|
||||||
* This can come from a collection filter or organization filter, if applied.
|
* This can come from a collection filter or organization filter, if applied.
|
||||||
*/
|
*/
|
||||||
get activeOrganizationId() {
|
protected get activeOrganizationId() {
|
||||||
if (this.activeFilter.selectedCollectionNode != null) {
|
if (this.collection != undefined) {
|
||||||
return this.activeFilter.selectedCollectionNode.node.organizationId;
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get title() {
|
protected get activeOrganization() {
|
||||||
if (this.activeFilter.isCollectionSelected) {
|
const organizationId = this.activeOrganizationId;
|
||||||
if (this.activeFilter.isUnassignedCollectionSelected) {
|
return this.organizations?.find((org) => org.id === organizationId);
|
||||||
return this.i18nService.t("unassigned");
|
|
||||||
}
|
|
||||||
return this.activeFilter.selectedCollectionNode.node.name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeFilter.isMyVaultSelected) {
|
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.collection) {
|
||||||
|
return this.collection.node.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filter.organizationId === Unassigned) {
|
||||||
return this.i18nService.t("myVault");
|
return this.i18nService.t("myVault");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeFilter?.selectedOrganizationNode != null) {
|
const activeOrganization = this.activeOrganization;
|
||||||
return `${this.activeFilter.selectedOrganizationNode.node.name} ${this.i18nService
|
if (activeOrganization) {
|
||||||
.t("vault")
|
return `${activeOrganization.name} ${this.i18nService.t("vault").toLowerCase()}`;
|
||||||
.toLowerCase()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.i18nService.t("allVaults");
|
return this.i18nService.t("allVaults");
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
|
/**
|
||||||
const filter = this.activeFilter;
|
* A list of collection filters that form a chain from the organization root to currently selected collection.
|
||||||
filter.resetFilter();
|
* Begins from the organization root and excludes the currently selected collection.
|
||||||
filter.selectedCollectionNode = collection;
|
*/
|
||||||
this.activeFilterChanged.emit(filter);
|
protected get collections() {
|
||||||
|
if (this.collection == undefined) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
addCipher() {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addCipher() {
|
||||||
this.onAddCipher.emit();
|
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>
|
||||||
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
|
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
|
||||||
<app-vault-header
|
<app-vault-header
|
||||||
[activeFilter]="activeFilter"
|
[filter]="filter"
|
||||||
[actionPromise]="vaultItemsComponent.actionPromise"
|
[loading]="refreshing && !performingInitialLoad"
|
||||||
|
[organizations]="allOrganizations"
|
||||||
|
[collection]="selectedCollection"
|
||||||
(onAddCipher)="addCipher()"
|
(onAddCipher)="addCipher()"
|
||||||
></app-vault-header>
|
></app-vault-header>
|
||||||
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
|
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
|
||||||
{{ trashCleanupWarning }}
|
{{ trashCleanupWarning }}
|
||||||
</app-callout>
|
</app-callout>
|
||||||
<app-vault-items
|
<app-vault-items
|
||||||
[activeFilter]="activeFilter"
|
[ciphers]="ciphers"
|
||||||
(onCipherClicked)="navigateToCipher($event)"
|
[collections]="collections"
|
||||||
(onAttachmentsClicked)="editCipherAttachments($event)"
|
[allCollections]="allCollections"
|
||||||
(onAddCipher)="addCipher()"
|
[allOrganizations]="allOrganizations"
|
||||||
(onShareClicked)="shareCipher($event)"
|
[disabled]="refreshing"
|
||||||
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
|
[showOwner]="true"
|
||||||
(onCloneClicked)="cloneCipher($event)"
|
[showCollections]="false"
|
||||||
(onOrganzationBadgeClicked)="applyOrganizationFilter($event)"
|
[showGroups]="false"
|
||||||
|
[showPremiumFeatures]="canAccessPremium"
|
||||||
|
[showBulkMove]="showBulkMove"
|
||||||
|
[showBulkTrashOptions]="filter.type === 'trash'"
|
||||||
|
[useEvents]="false"
|
||||||
|
[editableCollections]="false"
|
||||||
|
[cloneableOrganizationCiphers]="false"
|
||||||
|
(onEvent)="onVaultItemsEvent($event)"
|
||||||
>
|
>
|
||||||
</app-vault-items>
|
</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>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<div class="card border-warning mb-4" *ngIf="showUpdateKey">
|
<div class="card border-warning mb-4" *ngIf="showUpdateKey">
|
||||||
|
@ -96,5 +133,5 @@
|
||||||
<ng-template #folderAddEdit></ng-template>
|
<ng-template #folderAddEdit></ng-template>
|
||||||
<ng-template #cipherAddEdit></ng-template>
|
<ng-template #cipherAddEdit></ng-template>
|
||||||
<ng-template #share></ng-template>
|
<ng-template #share></ng-template>
|
||||||
<ng-template #collections></ng-template>
|
<ng-template #collectionsModal></ng-template>
|
||||||
<ng-template #updateKeyTemplate></ng-template>
|
<ng-template #updateKeyTemplate></ng-template>
|
||||||
|
|
|
@ -8,30 +8,69 @@ import {
|
||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||||
import { firstValueFrom, Subject } from "rxjs";
|
import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs";
|
||||||
import { first, switchMap, takeUntil } from "rxjs/operators";
|
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 { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
||||||
import { CryptoService } from "@bitwarden/common/abstractions/crypto.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 { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.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 { 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 { 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 { 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 { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
|
||||||
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
|
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { DialogService, Icons } from "@bitwarden/components";
|
||||||
|
|
||||||
import { UpdateKeyComponent } from "../../settings/update-key.component";
|
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 { AddEditComponent } from "./add-edit.component";
|
||||||
import { AttachmentsComponent } from "./attachments.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 { CollectionsComponent } from "./collections.component";
|
||||||
import { FolderAddEditComponent } from "./folder-add-edit.component";
|
import { FolderAddEditComponent } from "./folder-add-edit.component";
|
||||||
import { ShareComponent } from "./share.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 { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
|
||||||
import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service";
|
import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service";
|
||||||
import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.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 { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
|
||||||
import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
|
import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
|
||||||
import { VaultItemsComponent } from "./vault-items.component";
|
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "VaultComponent";
|
const BroadcasterSubscriptionId = "VaultComponent";
|
||||||
|
const SearchTextDebounceInterval = 200;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-vault",
|
selector: "app-vault",
|
||||||
|
@ -52,7 +97,6 @@ const BroadcasterSubscriptionId = "VaultComponent";
|
||||||
})
|
})
|
||||||
export class VaultComponent implements OnInit, OnDestroy {
|
export class VaultComponent implements OnInit, OnDestroy {
|
||||||
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
|
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
|
||||||
@ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent;
|
|
||||||
@ViewChild("attachments", { read: ViewContainerRef, static: true })
|
@ViewChild("attachments", { read: ViewContainerRef, static: true })
|
||||||
attachmentsModalRef: ViewContainerRef;
|
attachmentsModalRef: ViewContainerRef;
|
||||||
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
|
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
|
||||||
|
@ -60,7 +104,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
|
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
|
||||||
cipherAddEditModalRef: ViewContainerRef;
|
cipherAddEditModalRef: ViewContainerRef;
|
||||||
@ViewChild("share", { read: ViewContainerRef, static: true }) shareModalRef: ViewContainerRef;
|
@ViewChild("share", { read: ViewContainerRef, static: true }) shareModalRef: ViewContainerRef;
|
||||||
@ViewChild("collections", { read: ViewContainerRef, static: true })
|
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
|
||||||
collectionsModalRef: ViewContainerRef;
|
collectionsModalRef: ViewContainerRef;
|
||||||
@ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true })
|
@ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true })
|
||||||
updateKeyModalRef: ViewContainerRef;
|
updateKeyModalRef: ViewContainerRef;
|
||||||
|
@ -73,6 +117,23 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
trashCleanupWarning: string = null;
|
trashCleanupWarning: string = null;
|
||||||
kdfIterations: number;
|
kdfIterations: number;
|
||||||
activeFilter: VaultFilter = new VaultFilter();
|
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>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -82,6 +143,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
|
private dialogService: DialogService,
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private cryptoService: CryptoService,
|
private cryptoService: CryptoService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
|
@ -91,29 +153,33 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private vaultFilterService: VaultFilterService,
|
private vaultFilterService: VaultFilterService,
|
||||||
|
private routedVaultFilterService: RoutedVaultFilterService,
|
||||||
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
|
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
|
||||||
private cipherService: CipherService,
|
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() {
|
async ngOnInit() {
|
||||||
this.showVerifyEmail = !(await this.tokenService.getEmailVerified());
|
|
||||||
this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1;
|
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.trashCleanupWarning = this.i18nService.t(
|
||||||
this.platformUtilsService.isSelfHost()
|
this.platformUtilsService.isSelfHost()
|
||||||
? "trashCleanupWarningSelfHosted"
|
? "trashCleanupWarningSelfHosted"
|
||||||
: "trashCleanupWarning"
|
: "trashCleanupWarning"
|
||||||
);
|
);
|
||||||
|
|
||||||
this.route.queryParams
|
const firstSetup$ = this.route.queryParams.pipe(
|
||||||
.pipe(
|
|
||||||
first(),
|
first(),
|
||||||
switchMap(async (params: Params) => {
|
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);
|
await this.syncService.fullSync(false);
|
||||||
await this.vaultFilterService.reloadCollections();
|
|
||||||
await this.vaultItemsComponent.reload();
|
|
||||||
|
|
||||||
const canAccessPremium = await this.stateService.getCanAccessPremium();
|
const canAccessPremium = await this.stateService.getCanAccessPremium();
|
||||||
this.showPremiumCallout =
|
this.showPremiumCallout =
|
||||||
|
@ -132,6 +198,124 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
await this.editCipher(cipherView);
|
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(
|
||||||
switchMap(() => this.route.queryParams),
|
switchMap(() => this.route.queryParams),
|
||||||
switchMap(async (params) => {
|
switchMap(async (params) => {
|
||||||
const cipherId = getCipherIdFromParams(params);
|
const cipherId = getCipherIdFromParams(params);
|
||||||
|
@ -155,27 +339,54 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
firstSetup$
|
||||||
this.ngZone.run(async () => {
|
.pipe(
|
||||||
switch (message.command) {
|
switchMap(() => this.refresh$),
|
||||||
case "syncCompleted":
|
tap(() => (this.refreshing = true)),
|
||||||
if (message.successfully) {
|
switchMap(() =>
|
||||||
await Promise.all([
|
combineLatest([
|
||||||
this.vaultFilterService.reloadCollections(),
|
filter$,
|
||||||
this.vaultItemsComponent.load(this.vaultItemsComponent.filter),
|
canAccessPremium$,
|
||||||
]);
|
allCollections$,
|
||||||
this.changeDetectorRef.detectChanges();
|
this.organizationService.organizations$,
|
||||||
}
|
ciphers$,
|
||||||
break;
|
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$
|
this.showBulkMove =
|
||||||
.pipe(takeUntil(this.destroy$))
|
filter.type !== "trash" &&
|
||||||
.subscribe((activeFilter) => {
|
(filter.organizationId === undefined || filter.organizationId === Unassigned);
|
||||||
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.performingInitialLoad = false;
|
||||||
|
this.refreshing = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get isShowingCards() {
|
get isShowingCards() {
|
||||||
|
@ -198,6 +409,44 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.destroy$.complete();
|
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) {
|
async applyOrganizationFilter(orgId: string) {
|
||||||
if (orgId == null) {
|
if (orgId == null) {
|
||||||
orgId = "MyVault";
|
orgId = "MyVault";
|
||||||
|
@ -213,8 +462,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.folderAddEditModalRef,
|
this.folderAddEditModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.folderId = null;
|
comp.folderId = null;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedFolder.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -227,12 +475,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.folderAddEditModalRef,
|
this.folderAddEditModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.folderId = folder.id;
|
comp.folderId = folder.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedFolder.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onDeletedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onDeletedFolder.subscribe(async () => {
|
// 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();
|
modal.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -240,8 +494,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
};
|
};
|
||||||
|
|
||||||
filterSearchText(searchText: string) {
|
filterSearchText(searchText: string) {
|
||||||
this.vaultItemsComponent.searchText = searchText;
|
this.searchText$.next(searchText);
|
||||||
this.vaultItemsComponent.search(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async editCipherAttachments(cipher: CipherView) {
|
async editCipherAttachments(cipher: CipherView) {
|
||||||
|
@ -265,19 +518,21 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.attachmentsModalRef,
|
this.attachmentsModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.cipherId = cipher.id;
|
comp.cipherId = cipher.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
comp.onUploadedAttachment
|
||||||
comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
|
.pipe(takeUntil(this.destroy$))
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
.subscribe(() => (madeAttachmentChanges = true));
|
||||||
comp.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true));
|
comp.onDeletedAttachment
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
.pipe(takeUntil(this.destroy$))
|
||||||
comp.onReuploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
|
.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.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
modal.onClosed.subscribe(async () => {
|
|
||||||
if (madeAttachmentChanges) {
|
if (madeAttachmentChanges) {
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
madeAttachmentChanges = false;
|
madeAttachmentChanges = false;
|
||||||
});
|
});
|
||||||
|
@ -289,10 +544,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.shareModalRef,
|
this.shareModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.cipherId = cipher.id;
|
comp.cipherId = cipher.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSharedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSharedCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -304,10 +558,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.collectionsModalRef,
|
this.collectionsModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.cipherId = cipher.id;
|
comp.cipherId = cipher.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedCollections.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -351,20 +604,17 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.cipherAddEditModalRef,
|
this.cipherAddEditModalRef,
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.cipherId = id;
|
comp.cipherId = id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onDeletedCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onRestoredCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -381,6 +631,216 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
component.cloneMode = true;
|
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() {
|
async updateKey() {
|
||||||
await this.modalService.openViewRef(UpdateKeyComponent, this.updateKeyModalRef);
|
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;
|
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) {
|
private go(queryParams: any = null) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = {
|
queryParams = {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { NgModule } from "@angular/core";
|
||||||
import { BreadcrumbsModule } from "@bitwarden/components";
|
import { BreadcrumbsModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { LooseComponentsModule, SharedModule } from "../../shared";
|
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 { CollectionBadgeModule } from "../org-vault/collection-badge/collection-badge.module";
|
||||||
import { GroupBadgeModule } from "../org-vault/group-badge/group-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 { PipesModule } from "./pipes/pipes.module";
|
||||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||||
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
||||||
import { VaultItemsComponent } from "./vault-items.component";
|
|
||||||
import { VaultRoutingModule } from "./vault-routing.module";
|
import { VaultRoutingModule } from "./vault-routing.module";
|
||||||
import { VaultComponent } from "./vault.component";
|
import { VaultComponent } from "./vault.component";
|
||||||
|
|
||||||
|
@ -27,8 +27,9 @@ import { VaultComponent } from "./vault.component";
|
||||||
LooseComponentsModule,
|
LooseComponentsModule,
|
||||||
BulkDialogsModule,
|
BulkDialogsModule,
|
||||||
BreadcrumbsModule,
|
BreadcrumbsModule,
|
||||||
|
VaultItemsModule,
|
||||||
],
|
],
|
||||||
declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
|
declarations: [VaultComponent, VaultHeaderComponent],
|
||||||
exports: [VaultComponent],
|
exports: [VaultComponent],
|
||||||
})
|
})
|
||||||
export class VaultModule {}
|
export class VaultModule {}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared/shared.module";
|
||||||
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
|
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";
|
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared/shared.module";
|
||||||
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
|
import { PipesModule } from "../../individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
import { GroupNameBadgeComponent } from "./group-name-badge.component";
|
import { GroupNameBadgeComponent } from "./group-name-badge.component";
|
||||||
|
|
|
@ -1,21 +1,18 @@
|
||||||
import { Injectable, OnDestroy } from "@angular/core";
|
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 { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
|
|
||||||
import {
|
|
||||||
canAccessVaultTab,
|
|
||||||
OrganizationService,
|
|
||||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.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 { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
|
|
||||||
import { CollectionAdminView } from "../../../admin-console/organizations/core";
|
import {
|
||||||
import { CollectionAdminService } from "../../../admin-console/organizations/core/services/collection-admin.service";
|
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 { VaultFilterService as BaseVaultFilterService } from "../../individual-vault/vault-filter/services/vault-filter.service";
|
||||||
import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type";
|
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,
|
organizationService: OrganizationService,
|
||||||
folderService: FolderService,
|
folderService: FolderService,
|
||||||
cipherService: CipherService,
|
cipherService: CipherService,
|
||||||
collectionService: CollectionService,
|
|
||||||
policyService: PolicyService,
|
policyService: PolicyService,
|
||||||
i18nService: I18nService,
|
i18nService: I18nService,
|
||||||
protected collectionAdminService: CollectionAdminService
|
protected collectionAdminService: CollectionAdminService
|
||||||
|
@ -45,42 +41,13 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
|
||||||
organizationService,
|
organizationService,
|
||||||
folderService,
|
folderService,
|
||||||
cipherService,
|
cipherService,
|
||||||
collectionService,
|
|
||||||
policyService,
|
policyService,
|
||||||
i18nService
|
i18nService
|
||||||
);
|
);
|
||||||
this.loadSubscriptions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected loadSubscriptions() {
|
async reloadCollections(collections: CollectionAdminView[]) {
|
||||||
this._organizationFilter
|
|
||||||
.pipe(
|
|
||||||
filter((org) => org != null),
|
|
||||||
switchMap((org) => {
|
|
||||||
return this.loadCollections(org);
|
|
||||||
}),
|
|
||||||
takeUntil(this.destroy$)
|
|
||||||
)
|
|
||||||
.subscribe((collections) => {
|
|
||||||
this._collections.next(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
|
|
@ -1,29 +1,33 @@
|
||||||
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
|
<div class="tw-mb-4 tw-flex tw-items-start tw-justify-between">
|
||||||
<div>
|
<div>
|
||||||
<bit-breadcrumbs *ngIf="activeFilter.collectionBreadcrumbs.length > 0">
|
<bit-breadcrumbs *ngIf="showBreadcrumbs">
|
||||||
<bit-breadcrumb
|
<bit-breadcrumb
|
||||||
*ngFor="let collection of activeFilter.collectionBreadcrumbs; let first = first"
|
[route]="[]"
|
||||||
[icon]="first ? undefined : 'bwi-collection'"
|
[queryParams]="{ organizationId: organization.id, collectionId: null }"
|
||||||
(click)="applyCollectionFilter(collection)"
|
queryParamsHandling="merge"
|
||||||
>
|
>
|
||||||
<!-- First node in the tree is the "Org Name Vault" item. The rest come from user input. -->
|
{{ organization.name }} {{ "vault" | i18n | lowercase }}
|
||||||
<ng-container *ngIf="first">
|
|
||||||
{{ activeOrganizationId | orgNameFromId : (organizations$ | async) }}
|
|
||||||
{{ "vault" | i18n | lowercase }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="!first">{{ collection.node.name }}</ng-container>
|
|
||||||
</bit-breadcrumb>
|
</bit-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>
|
</bit-breadcrumbs>
|
||||||
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
|
<h1 class="tw-mb-0 tw-mt-1 tw-flex tw-items-center tw-space-x-2">
|
||||||
<i
|
<i
|
||||||
*ngIf="activeFilter.isCollectionSelected"
|
*ngIf="filter.collectionId !== undefined"
|
||||||
class="bwi bwi-collection"
|
class="bwi bwi-collection"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i>
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<ng-container
|
<ng-container *ngIf="collection !== undefined && (canEditCollection || canDeleteCollection)">
|
||||||
*ngIf="activeFilter.isCollectionSelected && !activeFilter.isUnassignedCollectionSelected"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
bitIconButton="bwi-angle-down"
|
bitIconButton="bwi-angle-down"
|
||||||
[bitMenuTriggerFor]="editCollectionMenu"
|
[bitMenuTriggerFor]="editCollectionMenu"
|
||||||
|
@ -34,27 +38,27 @@
|
||||||
<bit-menu #editCollectionMenu>
|
<bit-menu #editCollectionMenu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
|
*ngIf="canEditCollection"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'info')"
|
(click)="editCollection(CollectionDialogTabType.Info)"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||||
{{ "editInfo" | i18n }}
|
{{ "editInfo" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
*ngIf="canEditCollection(activeFilter.selectedCollectionNode.node)"
|
*ngIf="canEditCollection"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
(click)="editCollection(activeFilter.selectedCollectionNode.node, 'access')"
|
(click)="editCollection(CollectionDialogTabType.Access)"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||||
{{ "access" | i18n }}
|
{{ "access" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
*ngIf="canDeleteCollection(activeFilter.selectedCollectionNode.node)"
|
*ngIf="canDeleteCollection"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
(click)="deleteCollection(activeFilter.selectedCollectionNode.node)"
|
(click)="deleteCollection()"
|
||||||
>
|
>
|
||||||
<span class="tw-text-danger">
|
<span class="tw-text-danger">
|
||||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||||
|
@ -63,20 +67,18 @@
|
||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<small #actionSpinner [appApiAction]="actionPromise">
|
<small *ngIf="loading">
|
||||||
<ng-container *ngIf="$any(actionSpinner).loading">
|
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-spinner bwi-spin text-muted"
|
class="bwi bwi-spinner bwi-spin text-muted"
|
||||||
title="{{ 'loading' | i18n }}"
|
title="{{ 'loading' | i18n }}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i>
|
||||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
</ng-container>
|
|
||||||
</small>
|
</small>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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>
|
<div *ngIf="organization.canCreateNewCollections" appListDropdown>
|
||||||
<button
|
<button
|
||||||
bitButton
|
bitButton
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
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 { 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
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 { ProductType } from "@bitwarden/common/enums";
|
||||||
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
||||||
import {
|
import {
|
||||||
|
@ -22,90 +18,95 @@ import {
|
||||||
CollectionAdminService,
|
CollectionAdminService,
|
||||||
CollectionAdminView,
|
CollectionAdminView,
|
||||||
} from "../../../admin-console/organizations/core";
|
} from "../../../admin-console/organizations/core";
|
||||||
|
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared";
|
||||||
import {
|
import {
|
||||||
CollectionDialogResult,
|
All,
|
||||||
CollectionDialogTabType,
|
RoutedVaultFilterModel,
|
||||||
openCollectionDialog,
|
Unassigned,
|
||||||
} from "../../../admin-console/organizations/shared/components/collection-dialog";
|
} from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||||
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";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-org-vault-header",
|
selector: "app-org-vault-header",
|
||||||
templateUrl: "./vault-header.component.html",
|
templateUrl: "./vault-header.component.html",
|
||||||
})
|
})
|
||||||
export class VaultHeaderComponent {
|
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;
|
@Input() organization: Organization;
|
||||||
|
|
||||||
/**
|
/** Currently selected collection */
|
||||||
* Promise that is used to determine the loading state of the header via the ApiAction directive.
|
@Input() collection?: TreeNode<CollectionAdminView>;
|
||||||
* When the promise exists and is not resolved, the loading spinner will be shown.
|
|
||||||
*/
|
|
||||||
@Input() actionPromise: Promise<any>;
|
|
||||||
|
|
||||||
/**
|
/** Emits an event when the new item button is clicked in the header */
|
||||||
* The filter being actively applied to the vault view
|
|
||||||
*/
|
|
||||||
@Input() activeFilter: VaultFilter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits when the active filter has been modified by the header
|
|
||||||
*/
|
|
||||||
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits an event when a collection is modified or deleted via the header collection dropdown menu
|
|
||||||
*/
|
|
||||||
@Output() onCollectionChanged = new EventEmitter<CollectionView | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits an event when the new item button is clicked in the header
|
|
||||||
*/
|
|
||||||
@Output() onAddCipher = new EventEmitter<void>();
|
@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$;
|
protected organizations$ = this.organizationService.organizations$;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private vaultFilterService: VaultFilterService,
|
|
||||||
private platformUtilsService: PlatformUtilsService,
|
|
||||||
private apiService: ApiService,
|
|
||||||
private logService: LogService,
|
|
||||||
private collectionAdminService: CollectionAdminService,
|
private collectionAdminService: CollectionAdminService,
|
||||||
private router: Router
|
private router: Router
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
get title() {
|
||||||
* The id of the organization that is currently being filtered on.
|
if (this.collection !== undefined) {
|
||||||
* This can come from a collection filter, organization filter, or the current organization when viewed
|
return this.collection.node.name;
|
||||||
* 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.filter.collectionId === Unassigned) {
|
||||||
if (this.activeFilter.isCollectionSelected) {
|
|
||||||
return this.activeFilter.selectedCollectionNode.node.name;
|
|
||||||
}
|
|
||||||
if (this.activeFilter.isUnassignedCollectionSelected) {
|
|
||||||
return this.i18nService.t("unassigned");
|
return this.i18nService.t("unassigned");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${this.organization.name} ${this.i18nService.t("vault").toLowerCase()}`;
|
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 {
|
private showFreeOrgUpgradeDialog(): void {
|
||||||
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
|
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
|
||||||
title: this.i18nService.t("upgradeOrganization"),
|
title: this.i18nService.t("upgradeOrganization"),
|
||||||
|
@ -140,23 +141,16 @@ export class VaultHeaderComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
|
get canEditCollection(): boolean {
|
||||||
const filter = this.activeFilter;
|
// Only edit collections if not editing "Unassigned"
|
||||||
filter.resetFilter();
|
if (this.collection === undefined) {
|
||||||
filter.selectedCollectionNode = collection;
|
|
||||||
this.activeFilterChanged.emit(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
canEditCollection(c: CollectionAdminView): boolean {
|
|
||||||
// Only edit collections if we're in the org vault and not editing "Unassigned"
|
|
||||||
if (this.organization === undefined || c.id === null) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, check if we can edit the specified collection
|
// Otherwise, check if we can edit the specified collection
|
||||||
return (
|
return (
|
||||||
this.organization.canEditAnyCollection ||
|
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, {
|
this.onAddCollection.emit();
|
||||||
data: {
|
|
||||||
organizationId: this.organization?.id,
|
|
||||||
parentCollectionId: this.activeFilter.collectionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const result = await lastValueFrom(dialog.closed);
|
|
||||||
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
|
|
||||||
this.onCollectionChanged.emit(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async editCollection(c: CollectionView, tab: "info" | "access"): Promise<void> {
|
async editCollection(tab: CollectionDialogTabType): Promise<void> {
|
||||||
const tabType = tab == "info" ? CollectionDialogTabType.Info : CollectionDialogTabType.Access;
|
this.onEditCollection.emit({ tab });
|
||||||
|
|
||||||
const dialog = openCollectionDialog(this.dialogService, {
|
|
||||||
data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tabType },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await lastValueFrom(dialog.closed);
|
|
||||||
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
|
|
||||||
this.onCollectionChanged.emit(c);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canDeleteCollection(c: CollectionAdminView): boolean {
|
get canDeleteCollection(): boolean {
|
||||||
// Only delete collections if we're in the org vault and not deleting "Unassigned"
|
// Only delete collections if not deleting "Unassigned"
|
||||||
if (this.organization === undefined || c.id === null) {
|
if (this.collection === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, check if we can delete the specified collection
|
// Otherwise, check if we can delete the specified collection
|
||||||
return (
|
return (
|
||||||
this.organization?.canDeleteAnyCollection ||
|
this.organization?.canDeleteAnyCollection ||
|
||||||
(this.organization?.canDeleteAssignedCollections && c.assigned)
|
(this.organization?.canDeleteAssignedCollections && this.collection.node.assigned)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCollection(collection: CollectionView): Promise<void> {
|
deleteCollection() {
|
||||||
if (
|
this.onDeleteCollection.emit();
|
||||||
!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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
<div class="col-9">
|
<div class="col-9">
|
||||||
<app-org-vault-header
|
<app-org-vault-header
|
||||||
[activeFilter]="activeFilter"
|
[filter]="filter"
|
||||||
(onCollectionChanged)="refreshItems()"
|
[loading]="refreshing"
|
||||||
[actionPromise]="vaultItemsComponent.actionPromise"
|
|
||||||
(onAddCipher)="addCipher()"
|
|
||||||
[organization]="organization"
|
[organization]="organization"
|
||||||
|
[collection]="selectedCollection"
|
||||||
|
(onAddCipher)="addCipher()"
|
||||||
|
(onAddCollection)="addCollection()"
|
||||||
|
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
|
||||||
|
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
|
||||||
></app-org-vault-header>
|
></app-org-vault-header>
|
||||||
<app-callout
|
<app-callout
|
||||||
type="warning"
|
type="warning"
|
||||||
|
@ -31,21 +34,68 @@
|
||||||
>
|
>
|
||||||
{{ trashCleanupWarning }}
|
{{ trashCleanupWarning }}
|
||||||
</app-callout>
|
</app-callout>
|
||||||
<app-org-vault-items
|
<app-vault-items
|
||||||
[activeFilter]="activeFilter"
|
[ciphers]="ciphers"
|
||||||
[initOrganization]="organization"
|
[collections]="collections"
|
||||||
(onCipherClicked)="navigateToCipher($event)"
|
[allCollections]="allCollections"
|
||||||
(onAttachmentsClicked)="editCipherAttachments($event)"
|
[allOrganizations]="organization ? [organization] : []"
|
||||||
(onAddCipher)="addCipher()"
|
[allGroups]="allGroups"
|
||||||
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
|
[disabled]="loading"
|
||||||
(onEventsClicked)="viewEvents($event)"
|
[showOwner]="false"
|
||||||
(onCloneClicked)="cloneCipher($event)"
|
[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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ng-template #attachments></ng-template>
|
<ng-template #attachments></ng-template>
|
||||||
<ng-template #cipherAddEdit></ng-template>
|
<ng-template #cipherAddEdit></ng-template>
|
||||||
<ng-template #collections></ng-template>
|
<ng-template #collectionsModal></ng-template>
|
||||||
<ng-template #eventsTemplate></ng-template>
|
<ng-template #eventsTemplate></ng-template>
|
||||||
|
</div>
|
||||||
|
|
|
@ -8,35 +8,85 @@ import {
|
||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||||
import { combineLatest, firstValueFrom, Subject } from "rxjs";
|
import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs";
|
||||||
import { first, switchMap, takeUntil } from "rxjs/operators";
|
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 { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.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 { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
|
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
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 { 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 { 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 { 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 { 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 { 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 { AddEditComponent } from "./add-edit.component";
|
||||||
import { AttachmentsComponent } from "./attachments.component";
|
import { AttachmentsComponent } from "./attachments.component";
|
||||||
import { CollectionsComponent } from "./collections.component";
|
import { CollectionsComponent } from "./collections.component";
|
||||||
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
|
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
|
||||||
import { VaultItemsComponent } from "./vault-items.component";
|
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "OrgVaultComponent";
|
const BroadcasterSubscriptionId = "OrgVaultComponent";
|
||||||
|
const SearchTextDebounceInterval = 200;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-org-vault",
|
selector: "app-org-vault",
|
||||||
|
@ -44,21 +94,38 @@ const BroadcasterSubscriptionId = "OrgVaultComponent";
|
||||||
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
|
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
|
||||||
})
|
})
|
||||||
export class VaultComponent implements OnInit, OnDestroy {
|
export class VaultComponent implements OnInit, OnDestroy {
|
||||||
|
protected Unassigned = Unassigned;
|
||||||
|
|
||||||
@ViewChild("vaultFilter", { static: true })
|
@ViewChild("vaultFilter", { static: true })
|
||||||
vaultFilterComponent: VaultFilterComponent;
|
vaultFilterComponent: VaultFilterComponent;
|
||||||
@ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent;
|
|
||||||
@ViewChild("attachments", { read: ViewContainerRef, static: true })
|
@ViewChild("attachments", { read: ViewContainerRef, static: true })
|
||||||
attachmentsModalRef: ViewContainerRef;
|
attachmentsModalRef: ViewContainerRef;
|
||||||
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
|
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
|
||||||
cipherAddEditModalRef: ViewContainerRef;
|
cipherAddEditModalRef: ViewContainerRef;
|
||||||
@ViewChild("collections", { read: ViewContainerRef, static: true })
|
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
|
||||||
collectionsModalRef: ViewContainerRef;
|
collectionsModalRef: ViewContainerRef;
|
||||||
@ViewChild("eventsTemplate", { read: ViewContainerRef, static: true })
|
@ViewChild("eventsTemplate", { read: ViewContainerRef, static: true })
|
||||||
eventsModalRef: ViewContainerRef;
|
eventsModalRef: ViewContainerRef;
|
||||||
|
|
||||||
organization: Organization;
|
|
||||||
trashCleanupWarning: string = null;
|
trashCleanupWarning: string = null;
|
||||||
activeFilter: VaultFilter = new VaultFilter();
|
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>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -66,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
protected vaultFilterService: VaultFilterService,
|
protected vaultFilterService: VaultFilterService,
|
||||||
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
|
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
|
||||||
|
private routedVaultFilterService: RoutedVaultFilterService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
|
@ -77,7 +145,15 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
private ngZone: NgZone,
|
private ngZone: NgZone,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private passwordRepromptService: PasswordRepromptService
|
private passwordRepromptService: PasswordRepromptService,
|
||||||
|
private collectionAdminService: CollectionAdminService,
|
||||||
|
private searchService: SearchService,
|
||||||
|
private searchPipe: SearchPipe,
|
||||||
|
private groupService: GroupService,
|
||||||
|
private logService: LogService,
|
||||||
|
private eventCollectionService: EventCollectionService,
|
||||||
|
private totpService: TotpService,
|
||||||
|
private apiService: ApiService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
|
@ -87,25 +163,203 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
: "trashCleanupWarning"
|
: "trashCleanupWarning"
|
||||||
);
|
);
|
||||||
|
|
||||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
const filter$ = this.routedVaultFilterService.filter$;
|
||||||
this.organization = this.organizationService.get(params.organizationId);
|
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.routedVaultFilterBridgeService.activeFilter$
|
||||||
this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search;
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((activeFilter) => {
|
||||||
|
this.activeFilter = activeFilter;
|
||||||
});
|
});
|
||||||
|
|
||||||
// verifies that the organization has been set
|
this.searchText$
|
||||||
combineLatest([this.route.queryParams, this.route.parent.params])
|
.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(
|
.pipe(
|
||||||
switchMap(async ([qParams]) => {
|
switchMap(() => combineLatest([this.route.queryParams, organization$])),
|
||||||
|
switchMap(async ([qParams, organization]) => {
|
||||||
const cipherId = getCipherIdFromParams(qParams);
|
const cipherId = getCipherIdFromParams(qParams);
|
||||||
if (!cipherId) {
|
if (!cipherId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// Handle users with implicit collection access since they use the admin endpoint
|
// Handle users with implicit collection access since they use the admin endpoint
|
||||||
this.organization.canUseAdminCollections ||
|
organization.canUseAdminCollections ||
|
||||||
(await this.cipherService.get(cipherId)) != null
|
(await this.cipherService.get(cipherId)) != null
|
||||||
) {
|
) {
|
||||||
this.editCipherId(cipherId);
|
this.editCipherId(cipherId);
|
||||||
|
@ -125,30 +379,58 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
if (!this.organization.canUseAdminCollections) {
|
firstSetup$
|
||||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
.pipe(
|
||||||
this.ngZone.run(async () => {
|
switchMap(() => this.refresh$),
|
||||||
switch (message.command) {
|
tap(() => (this.refreshing = true)),
|
||||||
case "syncCompleted":
|
switchMap(() =>
|
||||||
if (message.successfully) {
|
combineLatest([
|
||||||
await Promise.all([
|
organization$,
|
||||||
this.vaultFilterService.reloadCollections(),
|
filter$,
|
||||||
this.vaultItemsComponent.refresh(),
|
allCollections$,
|
||||||
]);
|
allGroups$,
|
||||||
this.changeDetectorRef.detectChanges();
|
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.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;
|
||||||
}
|
}
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await this.syncService.fullSync(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.routedVaultFilterBridgeService.activeFilter$
|
get loading() {
|
||||||
.pipe(takeUntil(this.destroy$))
|
return this.refreshing || this.processingEvent;
|
||||||
.subscribe((activeFilter) => {
|
|
||||||
this.activeFilter = activeFilter;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
@ -157,15 +439,50 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshItems() {
|
async onVaultItemsEvent(event: VaultItemEvent) {
|
||||||
this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh();
|
this.processingEvent = true;
|
||||||
await this.vaultItemsComponent.actionPromise;
|
|
||||||
this.vaultItemsComponent.actionPromise = null;
|
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) {
|
filterSearchText(searchText: string) {
|
||||||
this.vaultItemsComponent.searchText = searchText;
|
this.searchText$.next(searchText);
|
||||||
this.vaultItemsComponent.search(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async editCipherAttachments(cipher: CipherView) {
|
async editCipherAttachments(cipher: CipherView) {
|
||||||
|
@ -182,17 +499,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
(comp) => {
|
(comp) => {
|
||||||
comp.organization = this.organization;
|
comp.organization = this.organization;
|
||||||
comp.cipherId = cipher.id;
|
comp.cipherId = cipher.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
comp.onUploadedAttachment
|
||||||
comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true));
|
.pipe(takeUntil(this.destroy$))
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
.subscribe(() => (madeAttachmentChanges = true));
|
||||||
comp.onDeletedAttachment.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.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
modal.onClosed.subscribe(async () => {
|
|
||||||
if (madeAttachmentChanges) {
|
if (madeAttachmentChanges) {
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
madeAttachmentChanges = false;
|
madeAttachmentChanges = false;
|
||||||
});
|
});
|
||||||
|
@ -208,10 +526,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null);
|
comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null);
|
||||||
comp.organization = this.organization;
|
comp.organization = this.organization;
|
||||||
comp.cipherId = cipher.id;
|
comp.cipherId = cipher.id;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedCollections.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -258,20 +575,17 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||||
comp.organization = this.organization;
|
comp.organization = this.organization;
|
||||||
comp.organizationId = this.organization.id;
|
comp.organizationId = this.organization.id;
|
||||||
comp.cipherId = cipherId;
|
comp.cipherId = cipherId;
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onSavedCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onDeletedCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
modal.close();
|
||||||
await this.vaultItemsComponent.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||||
comp.onRestoredCipher.subscribe(async () => {
|
|
||||||
modal.close();
|
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) {
|
async viewEvents(cipher: CipherView) {
|
||||||
await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => {
|
await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => {
|
||||||
comp.name = cipher.name;
|
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) {
|
private go(queryParams: any = null) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = {
|
queryParams = {
|
||||||
|
|
|
@ -6,12 +6,12 @@ import { LooseComponentsModule } from "../../shared/loose-components.module";
|
||||||
import { SharedModule } from "../../shared/shared.module";
|
import { SharedModule } from "../../shared/shared.module";
|
||||||
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
||||||
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.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 { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
|
||||||
import { GroupBadgeModule } from "./group-badge/group-badge.module";
|
import { GroupBadgeModule } from "./group-badge/group-badge.module";
|
||||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||||
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
||||||
import { VaultItemsComponent } from "./vault-items.component";
|
|
||||||
import { VaultRoutingModule } from "./vault-routing.module";
|
import { VaultRoutingModule } from "./vault-routing.module";
|
||||||
import { VaultComponent } from "./vault.component";
|
import { VaultComponent } from "./vault.component";
|
||||||
|
|
||||||
|
@ -26,8 +26,9 @@ import { VaultComponent } from "./vault.component";
|
||||||
OrganizationBadgeModule,
|
OrganizationBadgeModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
BreadcrumbsModule,
|
BreadcrumbsModule,
|
||||||
|
VaultItemsModule,
|
||||||
],
|
],
|
||||||
declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent],
|
declarations: [VaultComponent, VaultHeaderComponent],
|
||||||
exports: [VaultComponent],
|
exports: [VaultComponent],
|
||||||
})
|
})
|
||||||
export class VaultModule {}
|
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 { Collection } from "../domain/collection";
|
||||||
import { CollectionAccessDetailsResponse } from "../response/collection.response";
|
import { CollectionAccessDetailsResponse } from "../response/collection.response";
|
||||||
|
|
||||||
|
export const NestingDelimiter = "/";
|
||||||
|
|
||||||
export class CollectionView implements View, ITreeNodeObject {
|
export class CollectionView implements View, ITreeNodeObject {
|
||||||
id: string = null;
|
id: string = null;
|
||||||
organizationId: 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`
|
* 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
|
* @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(
|
static getTreeNodeObjectFromList<T extends ITreeNodeObject>(
|
||||||
nodeTree: TreeNode<ITreeNodeObject>[],
|
nodeTree: TreeNode<T>[],
|
||||||
id: string
|
id: string
|
||||||
): TreeNode<ITreeNodeObject> {
|
): TreeNode<T> {
|
||||||
for (let i = 0; i < nodeTree.length; i++) {
|
for (let i = 0; i < nodeTree.length; i++) {
|
||||||
if (nodeTree[i].node.id === id) {
|
if (nodeTree[i].node.id === id) {
|
||||||
return nodeTree[i];
|
return nodeTree[i];
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable no-useless-escape */
|
/* eslint-disable no-useless-escape */
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { Observable, of, switchMap } from "rxjs";
|
||||||
import { getHostname, parse } from "tldts";
|
import { getHostname, parse } from "tldts";
|
||||||
import { Merge } from "type-fest";
|
import { Merge } from "type-fest";
|
||||||
|
|
||||||
|
@ -526,6 +527,17 @@ export class Utils {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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) {
|
private static isAppleMobile(win: Window) {
|
||||||
return (
|
return (
|
||||||
win.navigator.userAgent.match(/iPhone/i) != null ||
|
win.navigator.userAgent.match(/iPhone/i) != null ||
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
|
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
|
||||||
|
import { QueryParamsHandling } from "@angular/router";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "bit-breadcrumb",
|
selector: "bit-breadcrumb",
|
||||||
|
@ -14,6 +15,9 @@ export class BreadcrumbComponent {
|
||||||
@Input()
|
@Input()
|
||||||
queryParams?: Record<string, string> = {};
|
queryParams?: Record<string, string> = {};
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
queryParamsHandling?: QueryParamsHandling;
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
click = new EventEmitter();
|
click = new EventEmitter();
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
class="tw-my-2 tw-inline-block"
|
class="tw-my-2 tw-inline-block"
|
||||||
[routerLink]="breadcrumb.route"
|
[routerLink]="breadcrumb.route"
|
||||||
[queryParams]="breadcrumb.queryParams"
|
[queryParams]="breadcrumb.queryParams"
|
||||||
|
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||||
>
|
>
|
||||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
|
@ -43,6 +44,7 @@
|
||||||
linkType="primary"
|
linkType="primary"
|
||||||
[routerLink]="breadcrumb.route"
|
[routerLink]="breadcrumb.route"
|
||||||
[queryParams]="breadcrumb.queryParams"
|
[queryParams]="breadcrumb.queryParams"
|
||||||
|
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||||
>
|
>
|
||||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
|
@ -64,6 +66,7 @@
|
||||||
class="tw-my-2 tw-inline-block"
|
class="tw-my-2 tw-inline-block"
|
||||||
[routerLink]="breadcrumb.route"
|
[routerLink]="breadcrumb.route"
|
||||||
[queryParams]="breadcrumb.queryParams"
|
[queryParams]="breadcrumb.queryParams"
|
||||||
|
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||||
>
|
>
|
||||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<!-- Please remove this disable statement when editing this file! -->
|
<!-- Please remove this disable statement when editing this file! -->
|
||||||
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
||||||
<table class="tw-w-full tw-table-auto tw-leading-normal tw-text-main">
|
<table [ngClass]="tableClass">
|
||||||
<thead
|
<thead
|
||||||
class="tw-text-bold tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-text-muted"
|
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 {
|
export class TableComponent implements OnDestroy, AfterContentChecked {
|
||||||
@Input() dataSource: TableDataSource<any>;
|
@Input() dataSource: TableDataSource<any>;
|
||||||
|
@Input() layout: "auto" | "fixed" = "auto";
|
||||||
|
|
||||||
@ContentChild(TableBodyDirective) templateVariable: TableBodyDirective;
|
@ContentChild(TableBodyDirective) templateVariable: TableBodyDirective;
|
||||||
|
|
||||||
|
@ -33,6 +34,15 @@ export class TableComponent implements OnDestroy, AfterContentChecked {
|
||||||
|
|
||||||
private _initialized = false;
|
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 {
|
ngAfterContentChecked(): void {
|
||||||
if (!this._initialized && isDataSource(this.dataSource)) {
|
if (!this._initialized && isDataSource(this.dataSource)) {
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
Loading…
Reference in New Issue