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