-
-
- {{ "all" | i18n }}
-
+
+ }
-
- {{ "addAccess" | i18n }}
-
-
-
- {{ trashCleanupWarning }}
-
-
+
+ }
+
+ @let freeTrial = freeTrialWhenWarningsServiceDisabled$ | async;
+ @if (!refreshing && freeTrial?.shownBanner) {
+
-
-
-
- {{ "noItemsInList" | i18n }}
-
-
-
-
-
-
+
+ }
+
+ @let resellerWarning = resellerWarningWhenWarningsServiceDisabled$ | async;
+ @if (!refreshing && resellerWarning) {
+
-
- {{ "loading" | i18n }}
+ {{ resellerWarning?.message }}
+
+ }
+
+ @if (filter) {
+
+ }
+
+
+ @let hideVaultFilters = hideVaultFilter$ | async;
+ @if (!hideVaultFilters) {
+
+ }
+
+
+ @if (showAddAccessToggle && activeFilter.selectedCollectionNode) {
+
+
+ {{ "all" | i18n }}
+
+
+
+ {{ "addAccess" | i18n }}
+
+
+ }
+
+ @if (activeFilter.isDeleted) {
+
+ {{ trashCleanupWarning }}
+
+ }
+
+ @if (filter) {
+
+
+ }
+
+ @let showCollectionAccessRestricted = showCollectionAccessRestricted$ | async;
+ @if (!refreshing && (isEmpty$ | async)) {
+ @if (!showCollectionAccessRestricted) {
+
+ {{ "noItemsInList" | i18n }}
+
+ @if (
+ filter &&
+ filter.type !== "trash" &&
+ filter.collectionId !== Unassigned &&
+ selectedCollection?.node?.canEditItems(organization)
+ ) {
+
+ }
+
+ } @else {
+
+
+ }
+ }
+ @if (refreshing) {
+
+
+ {{ "loading" | i18n }}
+
+ }
-
+}
diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts
index 8e4d844a871..69bcd22dde1 100644
--- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts
+++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts
@@ -1,6 +1,5 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Params, Router } from "@angular/router";
import {
BehaviorSubject,
@@ -11,6 +10,7 @@ import {
Observable,
of,
Subject,
+ zip,
} from "rxjs";
import {
catchError,
@@ -21,7 +21,9 @@ import {
first,
map,
shareReplay,
+ startWith,
switchMap,
+ take,
takeUntil,
tap,
} from "rxjs/operators";
@@ -52,6 +54,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -63,6 +66,10 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
+import {
+ CipherViewLike,
+ CipherViewLikeUtils,
+} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
BannerModule,
DialogRef,
@@ -163,54 +170,63 @@ enum AddAccessStatusType {
{ provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService },
],
})
-export class VaultComponent implements OnInit, OnDestroy {
+export class vNextVaultComponent implements OnInit, OnDestroy {
protected Unassigned = Unassigned;
- trashCleanupWarning: string = null;
+ trashCleanupWarning: string = this.i18nService.t(
+ this.platformUtilsService.isSelfHost()
+ ? "trashCleanupWarningSelfHosted"
+ : "trashCleanupWarning",
+ );
+
activeFilter: VaultFilter = new VaultFilter();
protected showAddAccessToggle = false;
protected noItemIcon = 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
| undefined;
- protected isEmpty: boolean;
- protected showCollectionAccessRestricted: boolean;
+ protected loading$: Observable;
+ protected processingEvent$ = new BehaviorSubject(false);
+ protected organization$: Observable;
+ protected allGroups$: Observable;
+ protected ciphers$: Observable;
+ protected allCiphers$: Observable;
+ protected showCollectionAccessRestricted$: Observable;
+
+ protected isEmpty$: Observable = of(false);
private hasSubscription$ = new BehaviorSubject(false);
- protected currentSearchText$: Observable;
protected useOrganizationWarningsService$: Observable;
protected freeTrialWhenWarningsServiceDisabled$: Observable;
protected resellerWarningWhenWarningsServiceDisabled$: Observable;
protected prevCipherId: string | null = null;
- protected userId: UserId;
+ protected userId$: Observable;
+
+ protected hideVaultFilter$: Observable;
+ protected currentSearchText$: Observable;
+ protected filter$: Observable;
+ private organizationId$: Observable;
+
+ private searchText$ = new Subject();
+ protected refreshingSubject$ = new BehaviorSubject(true);
+ private destroy$ = new Subject();
+ protected addAccessStatus$ = new BehaviorSubject(0);
+ private vaultItemDialogRef?: DialogRef | undefined;
+
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
*/
protected editableCollections$: Observable;
protected allCollectionsWithoutUnassigned$: Observable;
+ protected allCollections$: Observable;
+ protected collections$: Observable;
+ protected selectedCollection$: Observable | undefined>;
+ private nestedCollections$: Observable[]>;
- protected get hideVaultFilters(): boolean {
- return this.organization?.isProviderUser && !this.organization?.isMember;
- }
-
- private searchText$ = new Subject();
- private refresh$ = new BehaviorSubject(null);
- private destroy$ = new Subject();
- protected addAccessStatus$ = new BehaviorSubject(0);
- private vaultItemDialogRef?: DialogRef | undefined;
-
- @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent;
+ @ViewChild("vaultItems", { static: false }) vaultItemsComponent:
+ | VaultItemsComponent
+ | undefined;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
- map((account) => account?.id),
+ getUserId,
switchMap((id) =>
this.organizationService.organizations$(id).pipe(
filter((organizations) => organizations.length === 1),
@@ -271,56 +287,354 @@ export class VaultComponent implements OnInit, OnDestroy {
private billingNotificationService: BillingNotificationService,
private organizationWarningsService: OrganizationWarningsService,
private collectionService: CollectionService,
- ) {}
+ ) {
+ this.userId$ = this.accountService.activeAccount$.pipe(getUserId);
+ this.filter$ = this.routedVaultFilterService.filter$;
+ this.organizationId$ =
+ // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault,
+ // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here,
+ // but really we should change to using our own vault filter model that only represents valid states in AC.
+ this.filter$.pipe(
+ map((filter) => filter.organizationId),
+ filter((filter) => filter !== undefined),
+ filter(
+ (value: OrganizationId | Unassigned): value is OrganizationId => value !== Unassigned,
+ ),
+ distinctUntilChanged(),
+ );
- async ngOnInit() {
- this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
+ this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
- this.trashCleanupWarning = this.i18nService.t(
- this.platformUtilsService.isSelfHost()
- ? "trashCleanupWarningSelfHosted"
- : "trashCleanupWarning",
+ this.organization$ = combineLatest([this.organizationId$, this.userId$]).pipe(
+ switchMap(([orgId, userId]) =>
+ this.organizationService.organizations$(userId).pipe(getById(orgId)),
+ ),
+ filter((organization) => organization != null),
+ shareReplay({ refCount: true, bufferSize: 1 }),
);
- const filter$ = this.routedVaultFilterService.filter$;
-
- // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault,
- // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here,
- // but really we should change to using our own vault filter model that only represents valid states in AC.
- const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId =>
- value !== Unassigned;
- const organizationId$ = filter$.pipe(
- map((filter) => filter.organizationId),
- filter((filter) => filter !== undefined),
- filter(isOrganizationId),
- distinctUntilChanged(),
+ this.hideVaultFilter$ = this.organization$.pipe(
+ map((organization) => organization.isProviderUser && !organization.isMember),
);
- const organization$ = this.accountService.activeAccount$.pipe(
- map((account) => account?.id),
- switchMap((id) =>
- organizationId$.pipe(
- switchMap((organizationId) =>
- this.organizationService
- .organizations$(id)
- .pipe(map((organizations) => organizations.find((org) => org.id === organizationId))),
+ this.allCollectionsWithoutUnassigned$ = this.refreshingSubject$.pipe(
+ filter((refreshing) => refreshing),
+ switchMap(() => combineLatest([this.organizationId$, this.userId$])),
+ switchMap(([orgId, userId]) =>
+ this.collectionAdminService.collectionAdminViews$(orgId, userId),
+ ),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.allCollections$ = combineLatest([
+ this.organizationId$,
+ this.allCollectionsWithoutUnassigned$,
+ ]).pipe(
+ map(([organizationId, allCollections]) => {
+ // FIXME: We should not assert that the Unassigned type is a CollectionId.
+ // Instead we should consider representing the Unassigned collection as a different object, given that
+ // it is not actually a collection.
+ const noneCollection = new CollectionAdminView({
+ name: this.i18nService.t("unassigned"),
+ id: Unassigned as CollectionId,
+ organizationId: organizationId,
+ });
+ return allCollections.concat(noneCollection);
+ }),
+ );
+
+ this.nestedCollections$ = this.allCollections$.pipe(
+ map((collections) => getNestedCollectionTree(collections)),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.allGroups$ = this.organizationId$.pipe(
+ switchMap((organizationId) => this.groupService.getAll(organizationId)),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.allCiphers$ = combineLatest([
+ this.organization$,
+ this.userId$,
+ this.refreshingSubject$,
+ ]).pipe(
+ switchMap(async ([organization, userId]) => {
+ // If user swaps organization reset the addAccessToggle
+ if (!this.showAddAccessToggle || organization) {
+ this.addAccessToggle(0);
+ }
+ let ciphers;
+
+ // Restricted providers (who are not members) do not have access org cipher endpoint below
+ // Return early to avoid 404 response
+ if (!organization.isMember && organization.isProviderUser) {
+ return [];
+ }
+
+ // If the user can edit all ciphers for the organization then fetch them ALL.
+ if (organization.canEditAllCiphers) {
+ ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
+ ciphers.forEach((c) => (c.edit = true));
+ } else {
+ // Otherwise, only fetch ciphers they have access to (includes unassigned for admins).
+ ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id);
+ }
+
+ await this.searchService.indexCiphers(userId, ciphers, organization.id);
+ return ciphers;
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.selectedCollection$ = combineLatest([this.nestedCollections$, this.filter$]).pipe(
+ filter(([collections, filter]) => collections != undefined && filter != undefined),
+ map(([collections, filter]) => {
+ if (
+ filter.collectionId === undefined ||
+ filter.collectionId === All ||
+ filter.collectionId === Unassigned
+ ) {
+ return;
+ }
+
+ return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId);
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.showCollectionAccessRestricted$ = combineLatest([
+ this.filter$,
+ this.selectedCollection$,
+ this.organization$,
+ ]).pipe(
+ map(([filter, collection, organization]) => {
+ return (
+ (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) ||
+ (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned)
+ );
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.ciphers$ = combineLatest([
+ this.allCiphers$,
+ this.filter$,
+ this.currentSearchText$,
+ this.showCollectionAccessRestricted$,
+ this.userId$,
+ ]).pipe(
+ filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
+ concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted, userId]) => {
+ if (filter.collectionId === undefined && filter.type === undefined) {
+ return [];
+ }
+
+ if (showCollectionAccessRestricted) {
+ // Do not show ciphers for restricted collections
+ // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible
+ return [];
+ }
+
+ const filterFunction = createFilterFunction(filter);
+
+ if (await this.searchService.isSearchable(userId, searchText)) {
+ return await this.searchService.searchCiphers(
+ userId,
+ searchText,
+ [filterFunction],
+ ciphers,
+ );
+ }
+
+ return ciphers.filter(filterFunction);
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ // Billing Warnings
+ this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$(
+ FeatureFlag.UseOrganizationWarningsService,
+ );
+
+ const freeTrial$ = combineLatest([
+ this.organization$,
+ this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)),
+ ]).pipe(
+ filter(
+ ([org, hasSubscription]) => org.isOwner && hasSubscription && org.canViewBillingHistory,
+ ),
+ switchMap(([org]) =>
+ combineLatest([
+ of(org),
+ this.organizationApiService.getSubscription(org.id),
+ from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
+ map((paymentSource) => {
+ if (paymentSource == null) {
+ throw new Error("Payment source not found.");
+ }
+ return paymentSource;
+ }),
),
- takeUntil(this.destroy$),
- shareReplay({ refCount: false, bufferSize: 1 }),
+ ]),
+ ),
+ map(([org, sub, paymentSource]) =>
+ this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource),
+ ),
+ filter((result) => result !== null),
+ catchError((error: unknown) => {
+ this.billingNotificationService.handleError(error);
+ return of();
+ }),
+ );
+
+ this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe(
+ filter((enabled) => !enabled),
+ switchMap(() => freeTrial$),
+ );
+
+ this.resellerWarningWhenWarningsServiceDisabled$ = combineLatest([
+ this.organization$,
+ this.useOrganizationWarningsService$,
+ ]).pipe(
+ filter(([org, enabled]) => !enabled && org.isOwner),
+ switchMap(([org]) =>
+ from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
+ map((metadata) => ({ org, metadata })),
),
),
+ map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)),
);
- const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe(
+ this.organization$
+ .pipe(
+ switchMap((organization) =>
+ this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
+ ),
+ takeUntilDestroyed(),
+ )
+ .subscribe();
+
+ // End Billing Warnings
+
+ this.editableCollections$ = combineLatest([
+ this.allCollectionsWithoutUnassigned$,
+ this.organization$,
+ ]).pipe(
+ map(([collections, organization]) => {
+ // Users that can edit all ciphers can implicitly add to / edit within any collection
+ if (organization.canEditAllCiphers) {
+ return collections;
+ }
+ return collections.filter((c) => c.assigned);
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.collections$ = combineLatest([
+ this.nestedCollections$,
+ this.filter$,
+ this.currentSearchText$,
+ this.addAccessStatus$,
+ this.userId$,
+ this.organization$,
+ ]).pipe(
+ filter(([collections, filter]) => collections != undefined && filter != undefined),
+ concatMap(
+ async ([collections, filter, searchText, addAccessStatus, userId, organization]) => {
+ if (
+ filter.collectionId === Unassigned ||
+ (filter.collectionId === undefined && filter.type !== undefined)
+ ) {
+ return [];
+ }
+
+ this.showAddAccessToggle = false;
+ let searchableCollectionNodes: TreeNode[] = [];
+ if (filter.collectionId === undefined || filter.collectionId === All) {
+ searchableCollectionNodes = collections;
+ } else {
+ const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
+ collections,
+ filter.collectionId,
+ );
+ searchableCollectionNodes = selectedCollection.children ?? [];
+ }
+
+ let collectionsToReturn: CollectionAdminView[] = [];
+
+ if (await this.searchService.isSearchable(userId, searchText)) {
+ // Flatten the tree for searching through all levels
+ const flatCollectionTree: CollectionAdminView[] =
+ getFlatCollectionTree(searchableCollectionNodes);
+
+ collectionsToReturn = this.searchPipe.transform(
+ flatCollectionTree,
+ searchText,
+ (collection) => collection.name,
+ (collection) => collection.id,
+ );
+ } else {
+ collectionsToReturn = searchableCollectionNodes.map(
+ (treeNode: TreeNode): CollectionAdminView => treeNode.node,
+ );
+ }
+
+ // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit
+ this.showAddAccessToggle =
+ !organization.allowAdminAccessToAllCollectionItems &&
+ organization.canEditUnmanagedCollections &&
+ collectionsToReturn.some((c) => c.unmanaged);
+
+ if (addAccessStatus === 1 && this.showAddAccessToggle) {
+ collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged);
+ }
+ return collectionsToReturn;
+ },
+ ),
+ takeUntil(this.destroy$),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ const firstLoadComplete$ = zip([
+ this.organization$,
+ this.filter$,
+ this.allCollections$,
+ this.allGroups$,
+ this.ciphers$,
+ this.collections$,
+ this.selectedCollection$,
+ this.showCollectionAccessRestricted$,
+ ]).pipe(
+ map(() => true),
+ startWith(false),
+ take(2), // Only take the emmision from startsWith and the emission from zip.
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ this.loading$ = combineLatest([
+ this.refreshingSubject$,
+ this.processingEvent$,
+ firstLoadComplete$,
+ ]).pipe(
+ map(
+ ([refreshing, processing, firstLoadComplete]) =>
+ refreshing || processing || !firstLoadComplete,
+ ),
+ );
+ }
+
+ async ngOnInit() {
+ const firstSetup$ = combineLatest([this.organization$, this.route.queryParams]).pipe(
first(),
switchMap(async ([organization]) => {
- this.organization = organization;
-
if (!organization.canEditAnyCollection) {
await this.syncService.fullSync(false);
}
-
- return undefined;
+ return;
+ }),
+ catchError((error: unknown) => {
+ this.logService.error("Failed during firstSetup$:", error);
+ return of();
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -361,218 +675,13 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);
- this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
-
- this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe(
- switchMap(() => organizationId$),
- switchMap((orgId) =>
- this.accountService.activeAccount$.pipe(
- getUserId,
- switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)),
- ),
- ),
- shareReplay({ refCount: false, bufferSize: 1 }),
- );
-
- this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe(
- map((collections) => {
- // Users that can edit all ciphers can implicitly add to / edit within any collection
- if (this.organization.canEditAllCiphers) {
- return collections;
- }
- return collections.filter((c) => c.assigned);
- }),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
- const allCollections$ = combineLatest([
- organizationId$,
- this.allCollectionsWithoutUnassigned$,
- ]).pipe(
- map(([organizationId, allCollections]) => {
- // FIXME: We should not assert that the Unassigned type is a CollectionId.
- // Instead we should consider representing the Unassigned collection as a different object, given that
- // it is not actually a collection.
- return allCollections.concat(
- new CollectionAdminView({
- name: this.i18nService.t("unassigned"),
- id: Unassigned as CollectionId,
- organizationId,
- }),
- );
- }),
- );
-
- const allGroups$ = organizationId$.pipe(
- switchMap((organizationId) => this.groupService.getAll(organizationId)),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
- const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe(
- switchMap(async ([organization]) => {
- // If user swaps organization reset the addAccessToggle
- if (!this.showAddAccessToggle || organization) {
- this.addAccessToggle(0);
- }
- let ciphers;
-
- // Restricted providers (who are not members) do not have access org cipher endpoint below
- // Return early to avoid 404 response
- if (!organization.isMember && organization.isProviderUser) {
- return [];
- }
-
- // If the user can edit all ciphers for the organization then fetch them ALL.
- if (organization.canEditAllCiphers) {
- ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
- ciphers?.forEach((c) => (c.edit = true));
- } else {
- // Otherwise, only fetch ciphers they have access to (includes unassigned for admins).
- ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id);
- }
-
- await this.searchService.indexCiphers(this.userId, ciphers, organization.id);
- return ciphers;
- }),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
- const allCipherMap$ = allCiphers$.pipe(
+ const allCipherMap$ = this.allCiphers$.pipe(
map((ciphers) => {
return Object.fromEntries(ciphers.map((c) => [c.id, c]));
}),
);
- const nestedCollections$ = allCollections$.pipe(
- map((collections) => getNestedCollectionTree(collections)),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
- const collections$ = combineLatest([
- nestedCollections$,
- filter$,
- this.currentSearchText$,
- this.addAccessStatus$,
- ]).pipe(
- filter(([collections, filter]) => collections != undefined && filter != undefined),
- concatMap(async ([collections, filter, searchText, addAccessStatus]) => {
- if (
- filter.collectionId === Unassigned ||
- (filter.collectionId === undefined && filter.type !== undefined)
- ) {
- return [];
- }
-
- this.showAddAccessToggle = false;
- let searchableCollectionNodes: TreeNode[] = [];
- if (filter.collectionId === undefined || filter.collectionId === All) {
- searchableCollectionNodes = collections;
- } else {
- const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
- collections,
- filter.collectionId,
- );
- searchableCollectionNodes = selectedCollection?.children ?? [];
- }
-
- let collectionsToReturn: CollectionAdminView[] = [];
-
- if (await this.searchService.isSearchable(this.userId, searchText)) {
- // Flatten the tree for searching through all levels
- const flatCollectionTree: CollectionAdminView[] =
- getFlatCollectionTree(searchableCollectionNodes);
-
- collectionsToReturn = this.searchPipe.transform(
- flatCollectionTree,
- searchText,
- (collection) => collection.name,
- (collection) => collection.id,
- );
- } else {
- collectionsToReturn = searchableCollectionNodes.map(
- (treeNode: TreeNode): CollectionAdminView => treeNode.node,
- );
- }
-
- // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit
- this.showAddAccessToggle =
- !this.organization.allowAdminAccessToAllCollectionItems &&
- this.organization.canEditUnmanagedCollections &&
- collectionsToReturn.some((c) => c.unmanaged);
-
- if (addAccessStatus === 1 && this.showAddAccessToggle) {
- collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged);
- }
- 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 showCollectionAccessRestricted$ = combineLatest([
- filter$,
- selectedCollection$,
- organization$,
- ]).pipe(
- map(([filter, collection, organization]) => {
- return (
- (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) ||
- (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned)
- );
- }),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
- const ciphers$ = combineLatest([
- allCiphers$,
- filter$,
- this.currentSearchText$,
- showCollectionAccessRestricted$,
- ]).pipe(
- filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
- concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => {
- if (filter.collectionId === undefined && filter.type === undefined) {
- return [];
- }
-
- if (showCollectionAccessRestricted) {
- // Do not show ciphers for restricted collections
- // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible
- return [];
- }
-
- const filterFunction = createFilterFunction(filter);
-
- if (await this.searchService.isSearchable(this.userId, searchText)) {
- return await this.searchService.searchCiphers(
- this.userId,
- searchText,
- [filterFunction],
- ciphers,
- );
- }
-
- return ciphers.filter(filterFunction);
- }),
- shareReplay({ refCount: true, bufferSize: 1 }),
- );
-
+ // Handle deep linking to a specific cipher (if the route specifies a cipherId)
firstSetup$
.pipe(
switchMap(() => combineLatest([this.route.queryParams, allCipherMap$])),
@@ -620,7 +729,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} else {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("unknownCipher"),
});
await this.router.navigate([], {
@@ -633,9 +742,12 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.subscribe();
+ // Handle deep linking to a cipher event
firstSetup$
.pipe(
- switchMap(() => combineLatest([this.route.queryParams, organization$, allCiphers$])),
+ switchMap(() =>
+ combineLatest([this.route.queryParams, this.organization$, this.allCiphers$]),
+ ),
switchMap(async ([qParams, organization, allCiphers$]) => {
const cipherId = qParams.viewEvents;
if (!cipherId) {
@@ -647,7 +759,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} else {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("unknownCipher"),
});
await this.router.navigate([], {
@@ -660,125 +772,38 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.subscribe();
- // Billing Warnings
- this.useOrganizationWarningsService$ = this.configService.getFeatureFlag$(
- FeatureFlag.UseOrganizationWarningsService,
- );
-
- this.useOrganizationWarningsService$
+ combineLatest([this.useOrganizationWarningsService$, this.organization$])
.pipe(
- switchMap((enabled) =>
+ switchMap(([enabled, organization]) =>
enabled
- ? this.organizationWarningsService.showInactiveSubscriptionDialog$(this.organization)
+ ? this.organizationWarningsService.showInactiveSubscriptionDialog$(organization)
: this.unpaidSubscriptionDialog$,
),
takeUntil(this.destroy$),
)
.subscribe();
- organization$
- .pipe(
- switchMap((organization) =>
- this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
- ),
- takeUntil(this.destroy$),
- )
- .subscribe();
-
- const freeTrial$ = combineLatest([
- organization$,
- this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)),
- ]).pipe(
- filter(
- ([org, hasSubscription]) => org.isOwner && hasSubscription && org.canViewBillingHistory,
- ),
- switchMap(([org]) =>
- combineLatest([
- of(org),
- this.organizationApiService.getSubscription(org.id),
- from(this.organizationBillingService.getPaymentSource(org.id)).pipe(
- catchError((error: unknown) => {
- this.billingNotificationService.handleError(error);
- return of(null);
- }),
- ),
- ]),
- ),
- map(([org, sub, paymentSource]) =>
- this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(org, sub, paymentSource),
- ),
- filter((result) => result !== null),
- );
-
- this.freeTrialWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe(
- filter((enabled) => !enabled),
- switchMap(() => freeTrial$),
- );
-
- const resellerWarning$ = organization$.pipe(
- filter((org) => org.isOwner),
- switchMap((org) =>
- from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
- map((metadata) => ({ org, metadata })),
- ),
- ),
- map(({ org, metadata }) => this.resellerWarningService.getWarning(org, metadata)),
- );
-
- this.resellerWarningWhenWarningsServiceDisabled$ = this.useOrganizationWarningsService$.pipe(
- filter((enabled) => !enabled),
- switchMap(() => resellerWarning$),
- );
- // End Billing Warnings
-
+ // Handle last of initial setup - workaround for some state issues where we need to manually
+ // push the collections we've loaded back into the VaultFilterService.
+ // FIXME: figure out how we can remove this.
firstSetup$
.pipe(
- switchMap(() => this.refresh$),
- tap(() => (this.refreshing = true)),
- switchMap(() =>
- combineLatest([
- organization$,
- filter$,
- allCollections$,
- allGroups$,
- ciphers$,
- collections$,
- selectedCollection$,
- showCollectionAccessRestricted$,
- ]),
- ),
+ switchMap(() => this.allCollections$),
takeUntil(this.destroy$),
)
- .subscribe(
- ([
- organization,
- filter,
- allCollections,
- allGroups,
- ciphers,
- collections,
- selectedCollection,
- showCollectionAccessRestricted,
- ]) => {
- this.organization = organization;
- this.filter = filter;
- this.allCollections = allCollections;
- this.allGroups = allGroups;
- this.ciphers = ciphers;
- this.collections = collections;
- this.selectedCollection = selectedCollection;
- this.showCollectionAccessRestricted = showCollectionAccessRestricted;
-
- 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
+ .subscribe((allCollections) => {
+ // This is a temporary fix to avoid double fetching collections.
+ // TODO: Remove when implementing new VVR menu
+ if (this.vaultFilterService.reloadCollections) {
this.vaultFilterService.reloadCollections(allCollections);
+ }
- this.refreshing = false;
- this.performingInitialLoad = false;
- },
- );
+ this.refreshingSubject$.next(false);
+ });
+
+ this.isEmpty$ = combineLatest([this.ciphers$, this.collections$]).pipe(
+ map(([ciphers, collections]) => collections.length === 0 && ciphers?.length === 0),
+ );
}
async navigateToPaymentMethod() {
@@ -786,7 +811,8 @@ export class VaultComponent implements OnInit, OnDestroy {
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
- await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
+ const organizationId = await firstValueFrom(this.organizationId$);
+ await this.router.navigate(["organizations", `${organizationId}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}
@@ -795,10 +821,6 @@ export class VaultComponent implements OnInit, OnDestroy {
this.addAccessStatus$.next(e);
}
- get loading() {
- return this.refreshing || this.processingEvent;
- }
-
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.destroy$.next();
@@ -806,9 +828,10 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async onVaultItemsEvent(event: VaultItemEvent) {
- this.processingEvent = true;
+ this.processingEvent$.next(true);
try {
+ const organization = await firstValueFrom(this.organization$);
switch (event.type) {
case "viewAttachments":
await this.editCipherAttachments(event.item);
@@ -826,16 +849,18 @@ export class VaultComponent implements OnInit, OnDestroy {
case "delete": {
const ciphers = event.items
.filter((i) => i.collection === undefined)
- .map((i) => i.cipher);
+ .map((i) => i.cipher)
+ .filter((c) => c != null);
const collections = event.items
.filter((i) => i.cipher === undefined)
- .map((i) => i.collection);
+ .map((i) => i.collection)
+ .filter((c) => c != null);
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] as CollectionAdminView);
} else {
- await this.bulkDelete(ciphers, collections, this.organization);
+ await this.bulkDelete(ciphers, collections, organization);
}
break;
}
@@ -857,7 +882,7 @@ export class VaultComponent implements OnInit, OnDestroy {
);
break;
case "bulkEditCollectionAccess":
- await this.bulkEditCollectionAccess(event.items, this.organization);
+ await this.bulkEditCollectionAccess(event.items, organization);
break;
case "assignToCollections":
await this.bulkAssignToCollections(event.items);
@@ -867,7 +892,7 @@ export class VaultComponent implements OnInit, OnDestroy {
break;
}
} finally {
- this.processingEvent = false;
+ this.processingEvent$.next(false);
}
}
@@ -876,12 +901,13 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async editCipherAttachments(cipher: CipherView) {
- if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
+ if (cipher.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
return;
}
- if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) {
+ const organization = await firstValueFrom(this.organization$);
+ if (organization.maxStorageGb == null || organization.maxStorageGb === 0) {
this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId });
return;
}
@@ -895,8 +921,8 @@ export class VaultComponent implements OnInit, OnDestroy {
const result = await firstValueFrom(dialogRef.closed);
if (
- result.action === AttachmentDialogResult.Removed ||
- result.action === AttachmentDialogResult.Uploaded
+ result?.action === AttachmentDialogResult.Removed ||
+ result?.action === AttachmentDialogResult.Uploaded
) {
this.refresh();
}
@@ -906,14 +932,15 @@ export class VaultComponent implements OnInit, OnDestroy {
async addCipher(cipherType?: CipherType) {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
"add",
- null,
+ undefined,
cipherType,
);
const collectionId: CollectionId | undefined = this.activeFilter.collectionId as CollectionId;
+ const organization = await firstValueFrom(this.organization$);
cipherFormConfig.initialValues = {
- organizationId: this.organization.id as OrganizationId,
+ organizationId: organization.id,
collectionIds: collectionId ? [collectionId] : [],
};
@@ -925,7 +952,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* @param cipherView - When set, the cipher to be edited
* @param cloneCipher - `true` when the cipher should be cloned.
*/
- async editCipher(cipher: CipherView | null, cloneCipher: boolean) {
+ async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) {
if (
cipher &&
cipher.reprompt !== 0 &&
@@ -938,7 +965,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
cloneCipher ? "clone" : "edit",
- cipher?.id as CipherId | null,
+ cipher?.id as CipherId | undefined,
);
await this.openVaultItemDialog("form", cipherFormConfig, cipher);
@@ -983,7 +1010,8 @@ export class VaultComponent implements OnInit, OnDestroy {
cipher?: CipherView,
activeCollectionId?: CollectionId,
) {
- const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false;
+ const organization = await firstValueFrom(this.organization$);
+ const disableForm = cipher ? !cipher.edit && !organization.canEditAllCiphers : false;
// If the form is disabled, force the mode into `view`
const dialogMode = disableForm ? "view" : mode;
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
@@ -1008,7 +1036,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async cloneCipher(cipher: CipherView) {
- if (cipher.login?.hasFido2Credentials) {
+ if (cipher.login.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
@@ -1023,44 +1051,52 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCipher(cipher, true);
}
- restore = async (c: CipherView): Promise => {
- if (!c.isDeleted) {
- return;
+ restore = async (c: CipherViewLike): Promise => {
+ const organization = await firstValueFrom(this.organization$);
+ if (!CipherViewLikeUtils.isDeleted(c)) {
+ return false;
}
if (
- !this.organization.permissions.editAnyCollection &&
+ !organization.permissions.editAnyCollection &&
!c.edit &&
- !this.organization.allowAdminAccessToAllCollectionItems
+ !organization.allowAdminAccessToAllCollectionItems
) {
this.showMissingPermissionsError();
- return;
+ return false;
}
if (!(await this.repromptCipher([c]))) {
- return;
+ return false;
}
// Allow restore of an Unassigned Item
try {
+ if (c.id == null) {
+ throw new Error("Cipher must have an Id to be restored");
+ }
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
- const asAdmin = this.organization?.canEditAnyCollection || c.isUnassigned;
- await this.cipherService.restoreWithServer(c.id, activeUserId, asAdmin);
+ const organization = await firstValueFrom(this.organization$);
+ const asAdmin = organization.canEditAnyCollection || CipherViewLikeUtils.isUnassigned(c);
+ await this.cipherService.restoreWithServer(c.id as CipherId, activeUserId, asAdmin);
this.toastService.showToast({
variant: "success",
- title: null,
+
message: this.i18nService.t("restoredItem"),
});
this.refresh();
+ return true;
} catch (e) {
this.logService.error(e);
+ return false;
}
};
async bulkRestore(ciphers: CipherView[]) {
+ const organization = await firstValueFrom(this.organization$);
if (
- !this.organization.permissions.editAnyCollection &&
- ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems)
+ !organization.permissions.editAnyCollection &&
+ ciphers.some((c) => !c.edit && !organization.allowAdminAccessToAllCollectionItems)
) {
this.showMissingPermissionsError();
return;
@@ -1074,8 +1110,9 @@ export class VaultComponent implements OnInit, OnDestroy {
const editAccessCiphers: string[] = [];
const unassignedCiphers: string[] = [];
+ const userId = await firstValueFrom(this.userId$);
// If user has edit all Access no need to check for unassigned ciphers
- if (this.organization.canEditAllCiphers) {
+ if (organization.canEditAllCiphers) {
ciphers.map((cipher) => {
editAccessCiphers.push(cipher.id);
});
@@ -1101,27 +1138,28 @@ export class VaultComponent implements OnInit, OnDestroy {
if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) {
await this.cipherService.restoreManyWithServer(
[...unassignedCiphers, ...editAccessCiphers],
- this.userId,
- this.organization.id,
+ userId,
+ organization.id,
);
}
this.toastService.showToast({
variant: "success",
- title: null,
+
message: this.i18nService.t("restoredItems"),
});
this.refresh();
}
async deleteCipher(c: CipherView): Promise {
- if (!c.edit && !this.organization.canEditAllCiphers) {
+ const organization = await firstValueFrom(this.organization$);
+ if (!c.edit && !organization.canEditAllCiphers) {
this.showMissingPermissionsError();
- return;
+ return false;
}
if (!(await this.repromptCipher([c]))) {
- return;
+ return false;
}
const permanent = c.isDeleted;
@@ -1141,17 +1179,21 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.deleteCipherWithServer(c.id, activeUserId, permanent, c.isUnassigned);
this.toastService.showToast({
variant: "success",
- title: null,
+
message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"),
});
this.refresh();
+ return true;
} catch (e) {
this.logService.error(e);
+ return false;
}
}
async deleteCollection(collection: CollectionAdminView): Promise {
- if (!collection.canDelete(this.organization)) {
+ const organization = await firstValueFrom(this.organization$);
+ const userId = await firstValueFrom(this.userId$);
+ if (!collection.canDelete(organization)) {
this.showMissingPermissionsError();
return;
}
@@ -1165,11 +1207,11 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
try {
- await this.apiService.deleteCollection(this.organization?.id, collection.id);
- await this.collectionService.delete([collection.id as CollectionId], this.userId);
+ await this.apiService.deleteCollection(organization.id, collection.id);
+ await this.collectionService.delete([collection.id], userId);
this.toastService.showToast({
variant: "success",
- title: null,
+
message: this.i18nService.t("deletedCollectionId", collection.name),
});
@@ -1177,9 +1219,10 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.cipherService.clear();
// Navigate away if we deleted the collection we were viewing
- if (this.selectedCollection?.node.id === collection.id) {
+ const selectedCollection = await firstValueFrom(this.selectedCollection$);
+ if (selectedCollection?.node.id === collection.id) {
void this.router.navigate([], {
- queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
+ queryParams: { collectionId: selectedCollection.parent.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});
@@ -1215,25 +1258,27 @@ export class VaultComponent implements OnInit, OnDestroy {
if (ciphers.length === 0 && collections.length === 0) {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("nothingSelected"),
});
return;
}
+ const org = await firstValueFrom(this.organization$);
const canDeleteCollections =
collections == null || collections.every((c) => c.canDelete(organization));
const canDeleteCiphers =
- ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers;
+ ciphers == null || ciphers.every((c) => c.edit) || org.canEditAllCiphers;
if (!canDeleteCiphers || !canDeleteCollections) {
this.showMissingPermissionsError();
return;
}
+ const filter = await firstValueFrom(this.filter$);
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
- permanent: this.filter.type === "trash",
+ permanent: filter.type === "trash",
cipherIds: assignedCiphers,
collections: collections,
organization,
@@ -1263,12 +1308,12 @@ export class VaultComponent implements OnInit, OnDestroy {
} else if (field === "totp") {
aType = "TOTP";
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
- value = totpResponse?.code;
+ value = totpResponse.code;
typeI18nKey = "verificationCodeTotp";
} else {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("unexpectedError"),
});
return;
@@ -1288,7 +1333,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
- title: null,
+
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
});
@@ -1303,19 +1348,21 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async addCollection(): Promise {
+ const organization = await firstValueFrom(this.organization$);
+ const selectedCollection = await firstValueFrom(this.selectedCollection$);
const dialog = openCollectionDialog(this.dialogService, {
data: {
- organizationId: this.organization?.id,
- parentCollectionId: this.selectedCollection?.node.id,
- limitNestedCollections: !this.organization.canEditAnyCollection,
+ organizationId: organization.id,
+ parentCollectionId: selectedCollection?.node.id,
+ limitNestedCollections: !organization.canEditAnyCollection,
isAdminConsoleActive: true,
},
});
const result = await lastValueFrom(dialog.closed);
if (
- result.action === CollectionDialogAction.Saved ||
- result.action === CollectionDialogAction.Deleted
+ result?.action === CollectionDialogAction.Saved ||
+ result?.action === CollectionDialogAction.Deleted
) {
this.refresh();
}
@@ -1326,32 +1373,34 @@ export class VaultComponent implements OnInit, OnDestroy {
tab: CollectionDialogTabType,
readonly: boolean,
): Promise {
+ const organization = await firstValueFrom(this.organization$);
const dialog = openCollectionDialog(this.dialogService, {
data: {
- collectionId: c?.id,
- organizationId: this.organization?.id,
+ collectionId: c.id,
+ organizationId: organization.id,
initialTab: tab,
readonly: readonly,
isAddAccessCollection: c.unmanaged,
- limitNestedCollections: !this.organization.canEditAnyCollection,
+ limitNestedCollections: !organization.canEditAnyCollection,
isAdminConsoleActive: true,
},
});
const result = await lastValueFrom(dialog.closed);
if (
- result.action === CollectionDialogAction.Saved ||
- result.action === CollectionDialogAction.Deleted
+ result?.action === CollectionDialogAction.Saved ||
+ result?.action === CollectionDialogAction.Deleted
) {
this.refresh();
+ const selectedCollection = await firstValueFrom(this.selectedCollection$);
// If we deleted the selected collection, navigate up/away
if (
result.action === CollectionDialogAction.Deleted &&
- this.selectedCollection?.node.id === c?.id
+ selectedCollection?.node.id === c.id
) {
void this.router.navigate([], {
- queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
+ queryParams: { collectionId: selectedCollection.parent.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});
@@ -1366,7 +1415,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (collections.length === 0) {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("noCollectionsSelected"),
});
return;
@@ -1377,10 +1426,11 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
+ const org = await firstValueFrom(this.organization$);
const dialog = BulkCollectionsDialogComponent.open(this.dialogService, {
data: {
collections,
- organizationId: this.organization?.id,
+ organizationId: org.id,
},
});
@@ -1394,7 +1444,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (items.length === 0) {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("nothingSelected"),
});
return;
@@ -1402,14 +1452,15 @@ export class VaultComponent implements OnInit, OnDestroy {
const availableCollections = await firstValueFrom(this.editableCollections$);
+ const organization = await firstValueFrom(this.organization$);
const dialog = AssignCollectionsWebComponent.open(this.dialogService, {
data: {
ciphers: items,
- organizationId: this.organization?.id as OrganizationId,
+ organizationId: organization.id,
availableCollections,
- activeCollection: this.activeFilter?.selectedCollectionNode?.node,
+ activeCollection: this.activeFilter.selectedCollectionNode.node,
isSingleCipherAdmin:
- items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned),
+ items.length === 1 && (organization.canEditAllCiphers || items[0].isUnassigned),
},
});
@@ -1420,10 +1471,11 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async viewEvents(cipher: CipherView) {
- await openEntityEventsDialog(this.dialogService, {
+ const organization = await firstValueFrom(this.organization$);
+ openEntityEventsDialog(this.dialogService, {
data: {
name: cipher.name,
- organizationId: this.organization.id,
+ organizationId: organization.id,
entityId: cipher.id,
showUser: true,
entity: "cipher",
@@ -1431,27 +1483,30 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
- protected deleteCipherWithServer(
+ protected async deleteCipherWithServer(
id: string,
userId: UserId,
permanent: boolean,
isUnassigned: boolean,
) {
- const asAdmin = this.organization?.canEditAllCiphers || isUnassigned;
+ const organization = await firstValueFrom(this.organization$);
+ const asAdmin = organization.canEditAllCiphers || isUnassigned;
return permanent
? this.cipherService.deleteWithServer(id, userId, asAdmin)
: this.cipherService.softDeleteWithServer(id, userId, asAdmin);
}
- protected async repromptCipher(ciphers: CipherView[]) {
+ protected async repromptCipher(ciphers: CipherViewLike[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
private refresh() {
- this.refresh$.next();
- this.vaultItemsComponent?.clearSelection();
+ this.refreshingSubject$.next(true);
+ if (this.vaultItemsComponent) {
+ this.vaultItemsComponent.clearSelection();
+ }
}
private go(queryParams: any = null) {
@@ -1476,7 +1531,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private showMissingPermissionsError() {
this.toastService.showToast({
variant: "error",
- title: null,
+
message: this.i18nService.t("missingPermissions"),
});
}
diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts
index 1a093ff8352..92dbc5d832c 100644
--- a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts
+++ b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts
@@ -6,9 +6,10 @@ import { ViewComponent } from "../../../vault/individual-vault/view.component";
import { CollectionDialogComponent } from "../shared/components/collection-dialog";
import { CollectionNameBadgeComponent } from "./collection-badge";
+import { VaultComponent } from "./deprecated_vault.component";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultRoutingModule } from "./vault-routing.module";
-import { VaultComponent } from "./vault.component";
+import { vNextVaultComponent } from "./vault.component";
@NgModule({
imports: [
@@ -19,6 +20,7 @@ import { VaultComponent } from "./vault.component";
OrganizationBadgeModule,
CollectionDialogComponent,
VaultComponent,
+ vNextVaultComponent,
ViewComponent,
],
})
diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts
index 770cfd0011d..58d6d9efef9 100644
--- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts
+++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts
@@ -51,6 +51,9 @@ export function canAccessOrgAdmin(org: Organization): boolean {
);
}
+/**
+ * @deprecated Please use the general `getById` custom rxjs operator instead.
+ */
export function getOrganizationById(id: string) {
return map((orgs) => orgs.find((o) => o.id === id));
}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 30644b95627..b339798f914 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
+ CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors",
/* Auth */
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
@@ -71,6 +72,7 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
+ [FeatureFlag.CollectionVaultRefactor]: FALSE,
/* Autofill */
[FeatureFlag.NotificationRefresh]: FALSE,
diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts
index 5ef1d9bdc75..5cb4a7a084e 100644
--- a/libs/common/src/vault/utils/cipher-view-like-utils.ts
+++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts
@@ -80,6 +80,18 @@ export class CipherViewLikeUtils {
return cipher.isDeleted;
};
+ /** @returns `true` when the cipher is not assigned to a collection, `false` otherwise. */
+ static isUnassigned = (cipher: CipherViewLike): boolean => {
+ if (this.isCipherListView(cipher)) {
+ return (
+ cipher.organizationId != null &&
+ (cipher.collectionIds == null || cipher.collectionIds.length === 0)
+ );
+ }
+
+ return cipher.isUnassigned;
+ };
+
/** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */
static canAssignToCollections = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {