1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

[PM-22313] Refactor organization vault component (#16017)

* refactor organization, userId, and filter

* refactor collections

* refactor allGroups to observable

* Refactor ciphers WIP

* fix filter$

* refactor collections$, refresh$, isEmpty$, proccesingEvents$

* resolve remaining ts-strict errors

* refactor *ngIf to @if syntax

* rename function

* clean up

* fix issues from merge conflict

* better error handling, clean up

* wip add feature flag

* refactor org vault: improve null safety & loading

* add take(2) to firstLoadComplete observable

* add real feature flag

* cleanup

* fix icon

* Add comments

* refactor org vault with null checks, update util function

* fix type

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
Brandon Treston
2025-09-04 11:07:52 -04:00
committed by GitHub
parent 8c7faf49d5
commit 7247f4987e
9 changed files with 2373 additions and 613 deletions

View File

@@ -0,0 +1,156 @@
<app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-organization-reseller-renewal-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
>
</app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="secondary"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "clickHereToAddPaymentMethod" | i18n }}
</a>
</bit-banner>
</ng-container>
<ng-container *ngIf="resellerWarningWhenWarningsServiceDisabled$ | async as resellerWarning">
<bit-banner
id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="info"
[showClose]="false"
*ngIf="!refreshing"
>
{{ resellerWarning?.message }}
</bit-banner>
</ng-container>
<app-org-vault-header
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups"
[disabled]="loading"
[showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
<ng-container *ngIf="!performingInitialLoad && isEmpty">
<bit-no-items *ngIf="!showCollectionAccessRestricted">
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
</bit-no-items>
<collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)="
editCollection(selectedCollection.node, $event.tab, $event.readonly)
"
>
</collection-access-restricted>
</ng-container>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</div>
</div>

View File

@@ -1,19 +1,28 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { VaultComponent } from "./vault.component"; import { VaultComponent } from "./deprecated_vault.component";
import { vNextVaultComponent } from "./vault.component";
const routes: Routes = [ const routes: Routes = [
{ ...featureFlaggedRoute({
path: "", defaultComponent: VaultComponent,
component: VaultComponent, flaggedComponent: vNextVaultComponent,
canActivate: [organizationPermissionsGuard(canAccessVaultTab)], featureFlag: FeatureFlag.CollectionVaultRefactor,
routeOptions: {
data: { titleId: "vaults" }, data: { titleId: "vaults" },
path: "",
canActivate: [organizationPermissionsGuard(canAccessVaultTab)],
}, },
}),
]; ];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule], exports: [RouterModule],

View File

@@ -1,22 +1,31 @@
@let organization = organization$ | async;
@let selectedCollection = selectedCollection$ | async;
@let filter = filter$ | async;
@let refreshing = refreshingSubject$ | async;
@let loading = loading$ | async;
@if (organization) {
@if (useOrganizationWarningsService$ | async) {
<app-organization-free-trial-warning <app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization" [organization]="organization"
(clicked)="navigateToPaymentMethod()" (clicked)="navigateToPaymentMethod()"
> >
</app-organization-free-trial-warning> </app-organization-free-trial-warning>
<app-organization-reseller-renewal-warning }
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization" @if (useOrganizationWarningsService$ | async) {
> <app-organization-reseller-renewal-warning [organization]="organization">
</app-organization-reseller-renewal-warning> </app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial"> }
@let freeTrial = freeTrialWhenWarningsServiceDisabled$ | async;
@if (!refreshing && freeTrial?.shownBanner) {
<bit-banner <bit-banner
id="free-trial-banner" id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6" class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing" icon="bwi-billing"
bannerType="premium" bannerType="premium"
[showClose]="false" [showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
> >
{{ freeTrial.message }} {{ freeTrial.message }}
<a <a
@@ -29,20 +38,22 @@
{{ "clickHereToAddPaymentMethod" | i18n }} {{ "clickHereToAddPaymentMethod" | i18n }}
</a> </a>
</bit-banner> </bit-banner>
</ng-container> }
<ng-container *ngIf="resellerWarningWhenWarningsServiceDisabled$ | async as resellerWarning">
@let resellerWarning = resellerWarningWhenWarningsServiceDisabled$ | async;
@if (!refreshing && resellerWarning) {
<bit-banner <bit-banner
id="reseller-warning-banner" id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6" class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing" icon="bwi-billing"
bannerType="info" bannerType="info"
[showClose]="false" [showClose]="false"
*ngIf="!refreshing"
> >
{{ resellerWarning?.message }} {{ resellerWarning?.message }}
</bit-banner> </bit-banner>
</ng-container> }
@if (filter) {
<app-org-vault-header <app-org-vault-header
[filter]="filter" [filter]="filter"
[loading]="refreshing" [loading]="refreshing"
@@ -51,13 +62,16 @@
[searchText]="currentSearchText$ | async" [searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)" (onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()" (onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)" (onEditCollection)="editCollection(selectedCollection?.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)" (onDeleteCollection)="deleteCollection(selectedCollection?.node)"
(searchTextChanged)="filterSearchText($event)" (searchTextChanged)="filterSearchText($event)"
></app-org-vault-header> ></app-org-vault-header>
}
<div class="tw-flex tw-flex-row"> <div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters"> @let hideVaultFilters = hideVaultFilter$ | async;
@if (!hideVaultFilters) {
<div class="tw-w-1/4 tw-mr-5">
<app-organization-vault-filter <app-organization-vault-filter
[organization]="organization" [organization]="organization"
[activeFilter]="activeFilter" [activeFilter]="activeFilter"
@@ -65,9 +79,11 @@
(searchTextChanged)="filterSearchText($event)" (searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter> ></app-organization-vault-filter>
</div> </div>
}
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'"> <div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
@if (showAddAccessToggle && activeFilter.selectedCollectionNode) {
<bit-toggle-group <bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async" [selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)" (selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n" [attr.aria-label]="'addAccessFilter' | i18n"
@@ -80,16 +96,22 @@
{{ "addAccess" | i18n }} {{ "addAccess" | i18n }}
</bit-toggle> </bit-toggle>
</bit-toggle-group> </bit-toggle-group>
<bit-callout type="warning" *ngIf="activeFilter.isDeleted"> }
@if (activeFilter.isDeleted) {
<bit-callout type="warning">
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</bit-callout> </bit-callout>
}
@if (filter) {
<app-vault-items <app-vault-items
#vaultItems #vaultItems
[ciphers]="ciphers" [ciphers]="ciphers$ | async"
[collections]="collections" [collections]="collections$ | async"
[allCollections]="allCollections" [allCollections]="allCollections$ | async"
[allOrganizations]="organization ? [organization] : []" [allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups" [allGroups]="allGroups$ | async"
[disabled]="loading" [disabled]="loading"
[showOwner]="false" [showOwner]="false"
[showPermissionsColumn]="true" [showPermissionsColumn]="true"
@@ -113,38 +135,44 @@
[activeCollection]="selectedCollection?.node" [activeCollection]="selectedCollection?.node"
> >
</app-vault-items> </app-vault-items>
<ng-container *ngIf="!performingInitialLoad && isEmpty"> }
<bit-no-items *ngIf="!showCollectionAccessRestricted">
@let showCollectionAccessRestricted = showCollectionAccessRestricted$ | async;
@if (!refreshing && (isEmpty$ | async)) {
@if (!showCollectionAccessRestricted) {
<bit-no-items>
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span> <span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
@if (
filter &&
filter.type !== "trash" &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
) {
<button <button
slot="button" slot="button"
bitButton bitButton
(click)="addCipher()" (click)="addCipher()"
buttonType="primary" buttonType="primary"
type="button" type="button"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
> >
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }} <i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button> </button>
}
</bit-no-items> </bit-no-items>
} @else {
<collection-access-restricted <collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEditCollection]="selectedCollection?.node?.canEdit(organization)" [canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)" [canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)=" (viewCollectionClicked)="
editCollection(selectedCollection.node, $event.tab, $event.readonly) editCollection(selectedCollection?.node, $event.tab, $event.readonly)
" "
> >
</collection-access-restricted> </collection-access-restricted>
</ng-container> }
<div }
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start" @if (refreshing) {
*ngIf="performingInitialLoad" <div class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start">
>
<i <i
class="bwi bwi-spinner bwi-spin tw-text-muted" class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}" title="{{ 'loading' | i18n }}"
@@ -152,5 +180,7 @@
></i> ></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div> </div>
}
</div> </div>
</div> </div>
}

View File

@@ -6,9 +6,10 @@ import { ViewComponent } from "../../../vault/individual-vault/view.component";
import { CollectionDialogComponent } from "../shared/components/collection-dialog"; import { CollectionDialogComponent } from "../shared/components/collection-dialog";
import { CollectionNameBadgeComponent } from "./collection-badge"; import { CollectionNameBadgeComponent } from "./collection-badge";
import { VaultComponent } from "./deprecated_vault.component";
import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultRoutingModule } from "./vault-routing.module"; import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component"; import { vNextVaultComponent } from "./vault.component";
@NgModule({ @NgModule({
imports: [ imports: [
@@ -19,6 +20,7 @@ import { VaultComponent } from "./vault.component";
OrganizationBadgeModule, OrganizationBadgeModule,
CollectionDialogComponent, CollectionDialogComponent,
VaultComponent, VaultComponent,
vNextVaultComponent,
ViewComponent, ViewComponent,
], ],
}) })

View File

@@ -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) { export function getOrganizationById(id: string) {
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id)); return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
} }

View File

@@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag { export enum FeatureFlag {
/* Admin Console Team */ /* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location", CreateDefaultLocation = "pm-19467-create-default-location",
CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors",
/* Auth */ /* Auth */
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
@@ -71,6 +72,7 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = { export const DefaultFeatureFlagValue = {
/* Admin Console Team */ /* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE, [FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.CollectionVaultRefactor]: FALSE,
/* Autofill */ /* Autofill */
[FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.NotificationRefresh]: FALSE,

View File

@@ -80,6 +80,18 @@ export class CipherViewLikeUtils {
return cipher.isDeleted; 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. */ /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */
static canAssignToCollections = (cipher: CipherViewLike): boolean => { static canAssignToCollections = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) { if (this.isCipherListView(cipher)) {