1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[EC-14] Part II: Add Collection Rows to Vault List (#3875)

* [EC-14] initial refactoring of vault filter

* [EC-14] return observable trees for all filters with head node

* [EC-14] Remove bindings on callbacks

* [EC-14] fix formatting on disabled orgs

* [EC-14] hide MyVault if personal org policy

* [EC-14] add check for single org policy

* [EC-14] add policies to org and change node constructor

* [EC-14] don't show options if personal vault policy

* [EC-14] default to all vaults

* [EC-14] add default selection to filters

* [EC-14] finish filter model callbacks

* [EC-14] finish filter functionality and begin cleaning up

* [EC-14] clean up old components and start on org vault

* [EC-14] loop through filters for presentation

* [EC-14] refactor VaultFilterService and put filter presentation data back into Vault Filter component. Remove VaultService

* [EC-14] begin refactoring org vault

* [EC-14] Refactor Vault Filter Service to use observables

* [EC-14] finish org vault filter

* [EC-14] fix vault model tests

* [EC-14] fix org service calls

* [EC-14] pull refactor out of shared code

* [EC-14] include head node for collections even if collections aren't loaded yet

* [EC-14] fix url params for vaults

* [EC-14] remove comments

* [EC-14] Remove unnecesary getter for org on vault filter

* [EC-14] fix linter

* [EC-14] fix prettier

* [EC-14] add deprecated methods to collection service for desktop and browser

* [EC-14] simplify cipher type node check

* [EC-14] add getters to vault filter model

* [EC-14] refactor how we build the filter list into methods

* [EC-14] add getters to build filter method

* [EC-14] start adding header and collection rows

* [EC-14] remove param ids if false

* [EC-14] Make collection rows navigatable

* [EC-14] fix collapsing nodes

* [EC-14] add specific type to search placeholder

* [EC-14] remove extra constructor and comment from org vault filter

* [EC-14] extract subscription callback to methods

* [EC-14] Remove unecessary await

* [EC-14] Remove ternary operators while building org filter

* [EC-14] remove unnecessary deps array in vault filter service declaration

* [EC-14] consolidate new models into one file

* [EC-14] change name of edit collections method

* [EC-14] add collection badges to item rows

* [EC-14] show groups badge on collection rows

* [EC-14] add bulk actions to header menu button

* [EC-14] initialize nested observable inside of service

Signed-off-by: Jacob Fink <jfink@bitwarden.com>

* [EC-14] change how we load orgs into the vault filter and select the default filter

* [EC-14] remove get from getters name

* [EC-14] remove eslint-disable comment

* [EC-14] move vault filter service abstraction to angular folder and separate

* [EC-14] rename filter types and delete VaultFilterLabel

* [EC-14] remove changes to workspace file

* [EC-14] remove deprecated service from jslib module

* [EC-14] remove any remaining files from common code

* [EC-14] consolidate vault filter components into components folder

* [EC-14] simplify method call

* [EC-14] refactor the vault filter service
- orgs now have observable property
- BehaviorSubjects have been migrated to ReplaySubjects if they don't need starting value
- added unit tests
- fix small error when selecting org badge of personal vault
- renamed some properties

* [EC-14] replace mergeMap with switchMap in vault filter service

* [EC-14] early return to prevent nesting

* [EC-14] clean up filterCollections method

* [EC-14] use isDeleted helper in html

* [EC-14] add jsdoc comments to ServiceUtils

* [EC-14] fix linter

* [EC-14] use array.slice instead of setting length

* [EC-14] resolve merge conflicts

* [EC-14] remove checkbox from end user vault collection rows

* [EC-14] add owner column to collections in end user vault

* [EC-14] add a11y titles for vault filters

* Update apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* [EC-14] add missing high level jsdoc description

* [EC-14] fix storybook absolute imports

* [EC-14] delete vault-shared.module

* [EC-14] change search placeholder text to getter and add missing strings

* [EC-14] remove two way binding from search text in vault filter

* [EC-14] removed all binding from search text and just use input event

* [EC-14] remove async from apply vault filter

* [EC-14] remove circular observable calls in vault filter service

Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>

* [EC-14] move collapsed nodes to vault filter section

* [EC-14] deconstruct filter section inside component

* [EC-14] fix merge conflicts and introduce refactored organization service to vault filter service

* [EC-14] remove mutation from filter builders

* [EC-14] fix styling on buildFolderTree

* [EC-14] remove leftover folder-filters reference and use ternary for collapse icon

* [EC-14] remove unecessary checks

* [EC-14] stop rebuilding filters when the organization changes

* [EC-14] Move subscription out of setter in vault filter section

* [EC-14] remove extra policy service methods from vault filter service

* [EC-14] remove new methods from old vault-filter.service

* [EC-14] Use vault filter service in vault components

* [EC-14] reload collections from vault now that we have vault filter service

* [EC-14] remove currentFilterCollections in vault filter component

* [EC-14] change VaultFilterType to more specific OrganizationFilter in organization-options

* [EC-14] include org check in isNodeSelected

* [EC-14] add getters to filter function, fix storybook, and add test for All Collections

* [EC-14] Resolve merge conflicts

* [EC-14] fix merge conflicts

* [EC-14] fix merge conflicts: org service protected and remove absolute path

* [EC-14] separate org vault filter service observables

* [EC-14] remove folder subject in vault filter service

* [EC-14] remove collections subject from vault filter service

* [EC-14] change collection api call name
- getCollectionsWithDetails to getManyCollectionsWithDetails

* [EC-14] add collection functionality
- add endpoint to bulk delete collections
- add logic to bulk delete both ciphers and collections
- refresh ciphers list after making collection changes
- stop making api calls from ciphers list each time a filter changes

* [EC-14] get collections from vault filter service
- for badge, instead of passing through @Input variable

* [EC-14] only bulk delete collections if passed

* [EC-14] fix deleting ciphers in org vault
- reuse same logic from end user vault
- call different api endpoints

* [EC-14] include collections in MaxCheckedCount

* [EC-14] add paging to collections

* [EC-14] hide collections if searching

* [EC-14] change vault table to new table component
- removed a lot of scss classes to use tailwind alternatives
- added getters for arrays in component that template can reference
- imported and used new bitIconButton for options button

* [EC-14] remove cursor pointer when checkbox not available

* [EC-14] stop reloading cipher list too early

* [EC-14] stop setting cipher component to loaded too early
- loaded variable on cipher component hides the loaded indicator
- when setting the default filter, we were triggering that variable
- instead, we'll just set the active filter and let it grab the filter when ready

* [EC-14] check/navigate collection when clicked

* [EC-14] rename edit collections callback
- used to be onEditCollection
- renamed to onEditCipherCollections

* [EC-14] remove showOrganizationBadge property
- property used to tell template whether it was org vault or end user
- replace with check for organization property

* [EC-14] replace || with ?? in load function of ciphers

* [EC-14] remove nested subscriptions
- nested subscriptions = bad
- the only dependency any of the subscriptions have is on the organization
- use withLatestFrom to verify that the org has been set before firing

* [EC-14] add getters and rename method

* [EC-14] add null check in bulk delete component
- some input variables can be null, so we can't just check the length

* [EC-14] add ItemRow type
- ItemRow can be either CipherView or CollectionFilter
- Consolidated a large portion of selection logic

* [EC-14] remove extra applyFilter override
- Removed extra applyFIlter, allCiphers has already been filtered by org
- Also reordered some of the methods to make more sense

* [EC-14] remove extra collections uncheck

* [EC-14] transition bulk delete to dialog service

* [EC-14] transition bulk restore to dialog service

* [EC-14] transition bulk move to dialog service

* [EC-14] transition bulk share to dialog service

* [EC-14] remove modal references

* [EC-14] reload cipher list when changing orgs

* [EC-14] add helper method to bulk delete dialog
- Gives us built in typing instead of having to redeclare

* [EC-14] add helper to open bulk restore dialog
- Gives us typing without redeclaring

* [EC-14] add open helper to bulk move dialog

* [EC-14] add open helper to bulk share dialog
- Adds typing to data
- also removed the component refs from bulk actions

* [EC-14] remove modal service from bulk actions

* [EC-14] introduce VaultItemRow to combine cipher and collections

* [EC-14] show loading indicator while switching orgs

* [EC-14] remove indexing every time filter changes
- also reverted back to using setter for changing org

* [EC-14] allow searching by function in search pipe
- this allows us to search parent properties in objects

Co-authored-by: Andreas Coroiu <andreas@andreascoroiu.com>

* [EC-14] make collections searchable
- used search pipe to filter based on search text

* [EC-14] consolidate bulk dialogs in single module

* [EC-14] remove form promise from bulk dialogs

* [EC-14] stop casting dialog return type
- we now have a helper function that gives us typing on result

* [EC-14] add length check to array guard

* [EC-14] remove extra false assignment

* [EC-14] move to sentence case

* [EC-14] address pr feedback

* [EC-14] add back the default assignment to deleted
- we need this default assignment to check for null or undefined values

* [EC-14] remove optional chaining
- everything is initialized to an empty array so it should never be null

* [EC-14] remove manager check to show org vault
- this is fixed upstream in a more comprehensive way

* [EC-686] add tests and comments to serviceUtils (#4092)

* [EC-686] add tests and comments to serviceUtls

* [EC-686] whitelist spec filename from linter

* [EC-686] fix prettier

* [EC-14] use new collection admin service

* [EC-14] fix groups searching

* [EC-14] use new groups service and models

* [EC-14] fix shared module

* [EC-14] remove leftover empty vault filter service

* [EC-14] remove CollectionGroupDetailsView models

* [EC-14] replace GroupDetails with AdminView
- Collections in vault filter now use admin view to get access details
- Collections shown in cipher list use admin view for access details

* [EC-14] add back the dialog to shared module

* [EC-14] hide org vault if lacking permissions

* [EC-14] add edit collection dialog to vault

* [EC-14] add screen reader label to share dialog

* [EC-14] moved sync call below subscription
- the subscription gives a callback for when we finish a sync
- by awaiting the sync before we weren't using the callback to refresh

* [EC-14] move cipher params check to switchMap
- we want to avoid async subscriptions

* [EC-14] clean up subscriptions in org vault
- added takeUntil
- use combineLatest

* [EC-14] clean up vault subscriptions
- remove nested subscriptions
- use takeUntil

* [EC-14] init ciphers component first

* [EC-14] fix view vault tab permissions
- CanViewAssignedCollections doesn't include CanViewAllCollections
- CanViewAssignedCollections does include IsManager

* [EC-14] reduce nesting

* [EC-14] rename bulk action dialogs selectors

* [EC-14] fix permissions for collection management
- users with custom admin permissions should be able to edit as well

* [EC-14] prettier

* [EC-14] use percentages for table columns widths

* [EC-14] use GetCollectionAccessDetails in cli
- renamed api call

Signed-off-by: Jacob Fink <jfink@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>
Co-authored-by: Andreas Coroiu <andreas@andreascoroiu.com>
This commit is contained in:
Jake Fink
2022-12-19 12:40:00 -05:00
committed by GitHub
parent eef827eb19
commit f9a89916a2
70 changed files with 1891 additions and 832 deletions

View File

@@ -60,6 +60,7 @@
./libs/common/src/misc/nodeUtils.ts
./libs/common/src/misc/linkedFieldOption.decorator.ts
./libs/common/src/misc/serviceUtils.ts
./libs/common/src/misc/serviceUtils.spec.ts
./libs/common/src/types/twoFactorResponse.ts
./libs/common/src/types/authResponse.ts
./libs/common/src/types/syncEventArgs.ts

View File

@@ -416,7 +416,7 @@ export class GetCommand extends DownloadCommand {
throw new Error("No encryption key for this organization.");
}
const response = await this.apiService.getCollectionDetails(options.organizationId, id);
const response = await this.apiService.getCollectionAccessDetails(options.organizationId, id);
const decCollection = new CollectionView(response);
decCollection.name = await this.cryptoService.decryptToUtf8(
new EncString(response.name),

View File

@@ -17,7 +17,6 @@ import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStat
import { ProviderUserType } from "@bitwarden/common/enums/providerUserType";
import { Utils } from "@bitwarden/common/misc/utils";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/response/organization-user.response";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/models/response/provider/provider-user.response";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
@@ -182,7 +181,7 @@ export abstract class BasePeopleComponent<
this.didScroll = this.pagedUsers.length > this.pageSize;
}
checkUser(user: OrganizationUserUserDetailsResponse, select?: boolean) {
checkUser(user: UserType, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
}

View File

@@ -9,7 +9,6 @@ import {
CollectionAccessDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { CoreOrganizationModule } from "../core-organization.module";
import { CollectionAdminView } from "../views/collection-admin.view";
@@ -18,8 +17,11 @@ import { CollectionAdminView } from "../views/collection-admin.view";
export class CollectionAdminService {
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
async getAll(organizationId: string): Promise<CollectionView[]> {
const collectionResponse = await this.apiService.getCollections(organizationId);
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
const collectionResponse = await this.apiService.getManyCollectionsWithAccessDetails(
organizationId
);
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
return [];
}
@@ -31,7 +33,7 @@ export class CollectionAdminService {
organizationId: string,
collectionId: string
): Promise<CollectionAdminView | undefined> {
const collectionResponse = await this.apiService.getCollectionDetails(
const collectionResponse = await this.apiService.getCollectionAccessDetails(
organizationId,
collectionId
);

View File

@@ -1,4 +1,4 @@
import { View } from "@bitwarden/common/models/view/view";
import { View } from "@bitwarden/common/src/models/view/view";
import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response";

View File

@@ -7,7 +7,9 @@
[activeOrganization]="organization"
></app-organization-switcher>
<bit-tab-nav-bar class="-tw-mb-px">
<bit-tab-link route="vault">{{ "vault" | i18n }}</bit-tab-link>
<bit-tab-link *ngIf="canShowVaultTab(organization)" route="vault">{{
"vault" | i18n
}}</bit-tab-link>
<bit-tab-link *ngIf="canShowMembersTab(organization)" route="members">{{
"members" | i18n
}}</bit-tab-link>

View File

@@ -8,6 +8,7 @@ import {
canAccessMembersTab,
canAccessReportingTab,
canAccessSettingsTab,
canAccessVaultTab,
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
@@ -44,6 +45,10 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
this._destroy.complete();
}
canShowVaultTab(organization: Organization): boolean {
return canAccessVaultTab(organization);
}
canShowSettingsTab(organization: Organization): boolean {
return canAccessSettingsTab(organization);
}

View File

@@ -343,7 +343,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
private updateSearchedGroups() {
if (this.searchService.isSearchable(this.searchText)) {
// Making use of the pipe in the component as we need know which groups where filtered
this.searchedGroups = this.searchPipe.transform(this.groups, this.searchText, "name", "id");
this.searchedGroups = this.searchPipe.transform(
this.groups,
this.searchText,
(group) => group.details.name,
(group) => group.details.id
);
}
}
}

View File

@@ -133,7 +133,7 @@
<ng-container body>
<tr bitRow *ngFor="let u of searchedUsers" alignContent="middle">
<td bitCell (click)="checkUser(u)">
<input type="checkbox" [(ngModel)]="u.checked" />
<input type="checkbox" [(ngModel)]="$any(u).checked" />
</td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
@@ -195,7 +195,7 @@
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus(u)">
<ng-container *ngIf="showEnrolledStatus($any(u))">
<i
class="bwi bwi-key"
title="{{ 'enrolledPasswordReset' | i18n }}"
@@ -244,7 +244,7 @@
<button
type="button"
bitMenuItem
(click)="groups(u)"
(click)="groups($any(u))"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}

View File

@@ -3,11 +3,17 @@ import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import {
canAccessGroupsTab,
canAccessOrgAdmin,
canAccessGroupsTab,
canAccessMembersTab,
canAccessVaultTab,
canAccessReportingTab,
canAccessSettingsTab,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
import { OrganizationRedirectGuard } from "./guards/org-redirect.guard";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { CollectionsComponent } from "./manage/collections.component";
import { GroupsComponent } from "./manage/groups.component";
@@ -23,7 +29,15 @@ const routes: Routes = [
organizationPermissions: canAccessOrgAdmin,
},
children: [
{ path: "", pathMatch: "full", redirectTo: "vault" },
{
path: "",
pathMatch: "full",
canActivate: [OrganizationRedirectGuard],
data: {
autoRedirectCallback: getOrganizationRoute,
},
children: [], // This is required to make the auto redirect work, },
},
{
path: "vault",
loadChildren: () => VaultModule,
@@ -74,6 +88,25 @@ const routes: Routes = [
},
];
function getOrganizationRoute(organization: Organization): string {
if (canAccessVaultTab(organization)) {
return "vault";
}
if (canAccessMembersTab(organization)) {
return "members";
}
if (canAccessGroupsTab(organization)) {
return "groups";
}
if (canAccessReportingTab(organization)) {
return "reporting";
}
if (canAccessSettingsTab(organization)) {
return "settings";
}
return undefined;
}
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],

View File

@@ -15,7 +15,7 @@
<ng-container *ngIf="loading" #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
<bit-tab-group *ngIf="!loading">
<bit-tab-group *ngIf="!loading" [selectedIndex]="tabIndex">
<bit-tab label="{{ 'collectionInfo' | i18n }}">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>

View File

@@ -27,9 +27,15 @@ import {
PermissionMode,
} from "../access-selector";
export enum CollectionDialogTabType {
Info = 0,
Access = 1,
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
initialTab?: CollectionDialogTabType;
}
export enum CollectionDialogResult {
@@ -45,6 +51,7 @@ export enum CollectionDialogResult {
export class CollectionDialogComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected tabIndex: CollectionDialogTabType;
protected loading = true;
protected organization?: Organization;
protected collection?: CollectionView;
@@ -69,7 +76,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private collectionService: CollectionAdminService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService
) {}
) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
}
ngOnInit() {
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { PipesModule } from "../../../vault/pipes/pipes.module";
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";
@NgModule({
imports: [SharedModule, PipesModule],
declarations: [CollectionNameBadgeComponent],
exports: [CollectionNameBadgeComponent],
})
export class CollectionBadgeModule {}

View File

@@ -0,0 +1,6 @@
<ng-container *ngFor="let c of shownCollections">
<span bitBadge badgeType="secondary">{{ c | collectionNameFromId: collections }}</span>
</ng-container>
<ng-container *ngIf="showXMore">
<span bitBadge badgeType="secondary">+ {{ xMoreCount }} more</span>
</ng-container>

View File

@@ -0,0 +1,24 @@
import { Component, Input } from "@angular/core";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
@Component({
selector: "app-collection-badge",
templateUrl: "collection-name-badge.component.html",
})
export class CollectionNameBadgeComponent {
@Input() collectionIds: string[];
@Input() collections: CollectionView[];
get shownCollections(): string[] {
return this.showXMore ? this.collectionIds.slice(0, 2) : this.collectionIds;
}
get showXMore(): boolean {
return this.collectionIds.length > 3;
}
get xMoreCount(): number {
return this.collectionIds.length - 2;
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { PipesModule } from "../../../vault/pipes/pipes.module";
import { GroupNameBadgeComponent } from "./group-name-badge.component";
@NgModule({
imports: [SharedModule, PipesModule],
declarations: [GroupNameBadgeComponent],
exports: [GroupNameBadgeComponent],
})
export class GroupBadgeModule {}

View File

@@ -0,0 +1,6 @@
<ng-container *ngFor="let c of shownGroups">
<span bitBadge badgeType="secondary">{{ c.id | groupNameFromId: allGroups }}</span>
</ng-container>
<ng-container *ngIf="showXMore">
<span bitBadge badgeType="secondary">+ {{ xMoreCount }} more</span>
</ng-container>

View File

@@ -0,0 +1,26 @@
import { Component, Input } from "@angular/core";
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
import { GroupView } from "../../core";
@Component({
selector: "app-group-badge",
templateUrl: "group-name-badge.component.html",
})
export class GroupNameBadgeComponent {
@Input() selectedGroups: SelectionReadOnlyRequest[];
@Input() allGroups: GroupView[];
get shownGroups(): SelectionReadOnlyRequest[] {
return this.showXMore ? this.selectedGroups.slice(0, 2) : this.selectedGroups;
}
get showXMore(): boolean {
return this.selectedGroups.length > 3;
}
get xMoreCount(): number {
return this.selectedGroups.length - 2;
}
}

View File

@@ -29,7 +29,9 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements On
async ngOnInit() {
this.filters = await this.buildAllFilters();
if (!this.activeFilter.selectedCipherTypeNode) {
this.applyCollectionFilter((await this.getDefaultFilter()) as TreeNode<CollectionFilter>);
this.activeFilter.resetFilter();
this.activeFilter.selectedCollectionNode =
(await this.getDefaultFilter()) as TreeNode<CollectionFilter>;
}
this.isLoaded = true;
}

View File

@@ -1,25 +1,34 @@
import { Injectable } from "@angular/core";
import { combineLatestWith, ReplaySubject, switchMap, takeUntil } from "rxjs";
import { Injectable, OnDestroy } from "@angular/core";
import { filter, map, Observable, ReplaySubject, Subject, switchMap, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import {
canAccessVaultTab,
OrganizationService,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { CollectionData } from "@bitwarden/common/models/data/collection.data";
import { Collection } from "@bitwarden/common/models/domain/collection";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { VaultFilterService as BaseVaultFilterService } from "../../../vault/vault-filter/services/vault-filter.service";
import { CollectionFilter } from "../../../vault/vault-filter/shared/models/vault-filter.type";
import { CollectionAdminView } from "../../core";
import { CollectionAdminService } from "../../core/services/collection-admin.service";
@Injectable()
export class VaultFilterService extends BaseVaultFilterService {
protected collectionViews$ = new ReplaySubject<CollectionView[]>(1);
export class VaultFilterService extends BaseVaultFilterService implements OnDestroy {
private destroy$ = new Subject<void>();
private _collections = new ReplaySubject<CollectionAdminView[]>(1);
filteredCollections$: Observable<CollectionAdminView[]> = this._collections.asObservable();
collectionTree$: Observable<TreeNode<CollectionFilter>> = this.filteredCollections$.pipe(
map((collections) => this.buildCollectionTree(collections))
);
constructor(
stateService: StateService,
@@ -28,8 +37,8 @@ export class VaultFilterService extends BaseVaultFilterService {
cipherService: CipherService,
collectionService: CollectionService,
policyService: PolicyService,
protected apiService: ApiService,
i18nService: I18nService
i18nService: I18nService,
protected collectionAdminService: CollectionAdminService
) {
super(
stateService,
@@ -40,67 +49,42 @@ export class VaultFilterService extends BaseVaultFilterService {
policyService,
i18nService
);
this.loadSubscriptions();
}
protected loadSubscriptions() {
this.folderService.folderViews$
.pipe(
combineLatestWith(this._organizationFilter),
switchMap(async ([folders, org]) => {
return this.filterFolders(folders, org);
}),
takeUntil(this.destroy$)
)
.subscribe(this._filteredFolders);
this._organizationFilter
.pipe(
filter((org) => org != null),
switchMap((org) => {
return this.loadCollections(org);
})
)
.subscribe(this.collectionViews$);
this.collectionViews$
.pipe(
combineLatestWith(this._organizationFilter),
switchMap(async ([collections, org]) => {
if (org?.canUseAdminCollections) {
return collections;
} else {
return await this.filterCollections(collections, org);
}
}),
takeUntil(this.destroy$)
)
.subscribe(this._filteredCollections);
.subscribe((collections) => {
this._collections.next(collections);
});
}
protected async loadCollections(org: Organization) {
if (org?.permissions && org?.canEditAnyCollection) {
return await this.loadAdminCollections(org);
} else {
// TODO: remove when collections is refactored with observables
return await this.collectionService.getAllDecrypted();
}
async reloadCollections() {
this._collections.next(await this.loadCollections(this._organizationFilter.getValue()));
}
async loadAdminCollections(org: Organization): Promise<CollectionView[]> {
let collections: CollectionView[] = [];
if (org?.permissions && org?.canEditAnyCollection) {
const collectionResponse = await this.apiService.getCollections(org.id);
if (collectionResponse?.data != null && collectionResponse.data.length) {
const collectionDomains = collectionResponse.data.map(
(r: CollectionDetailsResponse) => new Collection(new CollectionData(r))
);
collections = await this.collectionService.decryptMany(collectionDomains);
}
protected async loadCollections(org: Organization): Promise<CollectionAdminView[]> {
let collections: CollectionAdminView[] = [];
if (canAccessVaultTab(org)) {
collections = await this.collectionAdminService.getAll(org.id);
const noneCollection = new CollectionView();
const noneCollection = new CollectionAdminView();
noneCollection.name = this.i18nService.t("unassigned");
noneCollection.organizationId = org.id;
collections.push(noneCollection);
}
return collections;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,5 +1,7 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -12,15 +14,40 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { TokenService } from "@bitwarden/common/abstractions/token.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import { VaultItemsComponent as BaseVaultItemsComponent } from "../../vault/vault-items.component";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "../../vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service";
import { CollectionFilter } from "../../vault/vault-filter/shared/models/vault-filter.type";
import {
VaultItemsComponent as BaseVaultItemsComponent,
VaultItemRow,
} from "../../vault/vault-items.component";
import { GroupService } from "../core/services/group/group.service";
import {
CollectionDialogResult,
CollectionDialogTabType,
openCollectionDialog,
} from "../shared/components/collection-dialog/collection-dialog.component";
const MaxCheckedCount = 500;
@Component({
selector: "app-org-vault-items",
templateUrl: "../../vault/vault-items.component.html",
})
export class VaultItemsComponent extends BaseVaultItemsComponent {
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
@Input() set initOrganization(value: Organization) {
this.organization = value;
this.changeOrganization();
}
@Output() onEventsClicked = new EventEmitter<CipherView>();
protected allCiphers: CipherView[] = [];
@@ -30,53 +57,81 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
cipherService: CipherService,
vaultFilterService: VaultFilterService,
eventCollectionService: EventCollectionService,
totpService: TotpService,
passwordRepromptService: PasswordRepromptService,
dialogService: DialogService,
logService: LogService,
stateService: StateService,
organizationService: OrganizationService,
tokenService: TokenService,
searchPipe: SearchPipe,
protected groupService: GroupService,
private apiService: ApiService
) {
super(
searchService,
i18nService,
platformUtilsService,
vaultFilterService,
cipherService,
eventCollectionService,
totpService,
stateService,
passwordRepromptService,
dialogService,
logService,
searchPipe,
organizationService,
tokenService
);
}
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
this.deleted = deleted || false;
if (this.organization.canEditAnyCollection) {
this.accessEvents = this.organization.useEvents;
this.allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
ngOnDestroy() {
super.ngOnDestroy();
}
async changeOrganization() {
this.groups = await this.groupService.getAll(this.organization?.id);
await this.loadCiphers();
await this.reload(this.activeFilter.buildFilter());
}
async loadCiphers() {
if (this.organization?.canEditAnyCollection) {
this.accessEvents = this.organization?.useEvents;
this.allCiphers = await this.cipherService.getAllFromApiForOrganization(
this.organization?.id
);
} else {
this.allCiphers = (await this.cipherService.getAllDecrypted()).filter(
(c) => c.organizationId === this.organization.id
(c) => c.organizationId === this.organization?.id
);
}
await this.searchService.indexCiphers(this.organization.id, this.allCiphers);
await this.searchService.indexCiphers(this.organization?.id, this.allCiphers);
}
async refreshCollections(): Promise<void> {
await this.vaultFilterService.reloadCollections();
if (this.activeFilter.selectedCollectionNode) {
this.activeFilter.selectedCollectionNode =
await this.vaultFilterService.getCollectionNodeFromTree(
this.activeFilter.selectedCollectionNode.node.id
);
}
}
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
this.deleted = deleted ?? false;
await this.applyFilter(filter);
this.loaded = true;
}
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
if (this.organization.canViewAllCollections) {
await super.applyFilter(filter);
} else {
const f = (c: CipherView) =>
c.organizationId === this.organization.id && (filter == null || filter(c));
await super.applyFilter(f);
}
async refresh() {
await this.loadCiphers();
await this.refreshCollections();
super.refresh();
}
async search(timeout: number = null) {
@@ -87,16 +142,136 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
this.onEventsClicked.emit(c);
}
protected deleteCipher(id: string) {
if (!this.organization.canEditAnyCollection) {
return super.deleteCipher(id, this.deleted);
protected showFixOldAttachments(c: CipherView) {
return this.organization?.canEditAnyCollection && c.hasOldAttachments;
}
checkAll(select: boolean) {
if (select) {
this.checkAll(false);
}
return this.deleted
const items: VaultItemRow[] = [...this.collections, ...this.ciphers];
if (!items.length) {
return;
}
const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length;
for (let i = 0; i < selectCount; i++) {
this.checkRow(items[i], select);
}
}
selectRow(item: VaultItemRow) {
this.checkRow(item);
}
checkRow(item: VaultItemRow, select?: boolean) {
if (item instanceof TreeNode && item.node.name == "Unassigned") {
return;
}
item.checked = select ?? !item.checked;
}
get selectedCollections(): TreeNode<CollectionFilter>[] {
if (!this.collections) {
return [];
}
return this.collections.filter((c) => !!(c as VaultItemRow).checked);
}
get selectedCollectionIds(): string[] {
return this.selectedCollections.map((c) => c.node.id);
}
async editCollection(c: CollectionView, tab: "info" | "access"): Promise<void> {
const tabType = tab == "info" ? CollectionDialogTabType.Info : CollectionDialogTabType.Access;
const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tabType },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async deleteCollection(collection: CollectionView): Promise<void> {
if (!this.organization.canDeleteAssignedCollections) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions")
);
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
collection.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return;
}
try {
this.actionPromise = this.apiService.deleteCollection(this.organization?.id, collection.id);
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", collection.name)
);
await this.refresh();
} catch (e) {
this.logService.error(e);
}
}
async bulkDelete() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
const selectedCollectionIds = this.deleted ? null : this.selectedCollectionIds;
if (!selectedCipherIds?.length && !selectedCollectionIds?.length) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.deleted,
cipherIds: selectedCipherIds,
collectionIds: selectedCollectionIds,
organization: this.organization,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
if (!this.organization?.canEditAnyCollection) {
return super.deleteCipherWithServer(id, this.deleted);
}
return permanent
? this.apiService.deleteCipherAdmin(id)
: this.apiService.putDeleteCipherAdmin(id);
}
protected showFixOldAttachments(c: CipherView) {
return this.organization.canEditAnyCollection && c.hasOldAttachments;
}
}

View File

@@ -1,12 +1,17 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessVaultTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
import { VaultComponent } from "./vault.component";
const routes: Routes = [
{
path: "",
component: VaultComponent,
data: { titleId: "vaults" },
canActivate: [OrganizationPermissionsGuard],
data: { titleId: "vaults", organizationPermissions: canAccessVaultTab },
},
];
@NgModule({

View File

@@ -55,10 +55,13 @@
{{ trashCleanupWarning }}
</app-callout>
<app-org-vault-items
[activeFilter]="activeFilter"
[initOrganization]="organization"
(activeFilterChanged)="applyVaultFilter($event)"
(onCipherClicked)="editCipher($event)"
(onAttachmentsClicked)="editCipherAttachments($event)"
(onAddCipher)="addCipher()"
(onCollectionsClicked)="editCipherCollections($event)"
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
(onEventsClicked)="viewEvents($event)"
(onCloneClicked)="cloneCipher($event)"
>

View File

@@ -8,8 +8,8 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { combineLatest, firstValueFrom, Subject } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
@@ -55,11 +55,12 @@ export class VaultComponent implements OnInit, OnDestroy {
organization: Organization;
trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter();
private destroy$ = new Subject<void>();
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
private vaultFilterService: VaultFilterService,
protected vaultFilterService: VaultFilterService,
private router: Router,
private changeDetectorRef: ChangeDetectorRef,
private syncService: SyncService,
@@ -73,82 +74,75 @@ export class VaultComponent implements OnInit, OnDestroy {
private passwordRepromptService: PasswordRepromptService
) {}
ngOnInit() {
async ngOnInit() {
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
: "trashCleanupWarning"
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.params.subscribe(async (params: any) => {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.organization = this.organizationService.get(params.organizationId);
this.vaultItemsComponent.organization = this.organization;
});
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search;
if (!this.organization.canViewAllCollections) {
await this.syncService.fullSync(false);
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;
}
});
});
}
this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => {
this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search;
});
await this.vaultItemsComponent.reload(
this.activeFilter.buildFilter(),
this.activeFilter.isDeleted
);
if (qParams.viewEvents != null) {
const cipher = this.vaultItemsComponent.ciphers.filter(
(c) => c.id === qParams.viewEvents
);
if (cipher.length > 0) {
this.viewEvents(cipher[0]);
// verifies that the organization has been set
combineLatest([this.route.queryParams, this.route.parent.params])
.pipe(
switchMap(async ([qParams, params]) => {
const cipherId = getCipherIdFromParams(qParams);
if (!cipherId) {
return;
}
}
if (
// Handle users with implicit collection access since they use the admin endpoint
this.organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null
) {
this.editCipherId(cipherId);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher")
);
this.router.navigate([], {
queryParams: { cipherId: null, itemId: null },
queryParamsHandling: "merge",
});
}
}),
takeUntil(this.destroy$)
)
.subscribe();
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.subscribe(async (params) => {
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
if (
// Handle users with implicit collection access since they use the admin endpoint
this.organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null
) {
this.editCipherId(cipherId);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher")
);
this.router.navigate([], {
queryParams: { cipherId: null, itemId: null },
queryParamsHandling: "merge",
});
}
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);
}
}
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.destroy$.next();
this.destroy$.complete();
}
async applyVaultFilter(filter: VaultFilter) {

View File

@@ -5,6 +5,8 @@ import { SharedModule } from "../../shared/shared.module";
import { OrganizationBadgeModule } from "../../vault/organization-badge/organization-badge.module";
import { PipesModule } from "../../vault/pipes/pipes.module";
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultItemsComponent } from "./vault-items.component";
import { VaultRoutingModule } from "./vault-routing.module";
@@ -16,6 +18,8 @@ import { VaultComponent } from "./vault.component";
VaultFilterModule,
SharedModule,
LooseComponentsModule,
GroupBadgeModule,
CollectionBadgeModule,
OrganizationBadgeModule,
PipesModule,
],

View File

@@ -104,10 +104,6 @@ import { AddEditCustomFieldsComponent } from "../vault/add-edit-custom-fields.co
import { AddEditComponent } from "../vault/add-edit.component";
import { AttachmentsComponent } from "../vault/attachments.component";
import { BulkActionsComponent } from "../vault/bulk-actions.component";
import { BulkDeleteComponent } from "../vault/bulk-delete.component";
import { BulkMoveComponent } from "../vault/bulk-move.component";
import { BulkRestoreComponent } from "../vault/bulk-restore.component";
import { BulkShareComponent } from "../vault/bulk-share.component";
import { CollectionsComponent } from "../vault/collections.component";
import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
import { ShareComponent } from "../vault/share.component";
@@ -135,10 +131,6 @@ import { SharedModule } from "./shared.module";
AttachmentsComponent,
BillingSyncKeyComponent,
BulkActionsComponent,
BulkDeleteComponent,
BulkMoveComponent,
BulkRestoreComponent,
BulkShareComponent,
ChangeEmailComponent,
ChangeKdfComponent,
ChangePasswordComponent,
@@ -246,10 +238,6 @@ import { SharedModule } from "./shared.module";
ApiKeyComponent,
AttachmentsComponent,
BulkActionsComponent,
BulkDeleteComponent,
BulkMoveComponent,
BulkRestoreComponent,
BulkShareComponent,
ChangeEmailComponent,
ChangeKdfComponent,
ChangePasswordComponent,

View File

@@ -13,10 +13,10 @@ import {
BadgeListModule,
BadgeModule,
ButtonModule,
IconButtonModule,
CalloutModule,
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
@@ -56,15 +56,16 @@ import "./locales";
ButtonModule,
CalloutModule,
DialogModule,
MultiSelectModule,
FormFieldModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
MultiSelectModule,
NavigationModule,
TableModule,
TabsModule,
ToggleGroupModule,
// Web specific
],
@@ -86,16 +87,16 @@ import "./locales";
ButtonModule,
CalloutModule,
DialogModule,
MultiSelectModule,
FormFieldModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
MultiSelectModule,
NavigationModule,
TableModule,
ToggleGroupModule,
LinkModule,
TabsModule,
ToggleGroupModule,
// Web specific
],

View File

@@ -0,0 +1,25 @@
<bit-simple-dialog>
<span bitDialogTitle>
{{ (permanent ? "permanentlyDeleteSelected" : "deleteSelected") | i18n }}
</span>
<span bitDialogContent>
<ng-container *ngIf="!permanent">
<span *ngIf="cipherIds?.length">
{{ "deleteSelectedItemsDesc" | i18n: cipherIds.length }}
</span>
<span *ngIf="collectionIds?.length">
{{ "deleteSelectedCollectionsDesc" | i18n: collectionIds.length }}
</span>
{{ "deleteSelectedConfirmation" | i18n }}
</ng-container>
<ng-container *ngIf="permanent">
{{ "permanentlyDeleteSelectedItemsDesc" | i18n: cipherIds.length }}
</ng-container>
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton type="submit" buttonType="danger" [bitAction]="submit">
{{ (permanent ? "permanentlyDelete" : "delete") | i18n }}
</button>
<button bitButton type="button" (click)="cancel()">{{ "cancel" | i18n }}</button>
</div>
</bit-simple-dialog>

View File

@@ -0,0 +1,134 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { CipherBulkDeleteRequest } from "@bitwarden/common/models/request/cipher-bulk-delete.request";
import { CollectionBulkDeleteRequest } from "@bitwarden/common/models/request/collection-bulk-delete.request";
import { DialogService } from "@bitwarden/components";
export interface BulkDeleteDialogParams {
cipherIds?: string[];
collectionIds?: string[];
permanent?: boolean;
organization?: Organization;
}
export enum BulkDeleteDialogResult {
Deleted = "deleted",
Canceled = "canceled",
}
/**
* Strongly typed helper to open a BulkDeleteDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openBulkDeleteDialog = (
dialogService: DialogService,
config: DialogConfig<BulkDeleteDialogParams>
) => {
return dialogService.open<BulkDeleteDialogResult, BulkDeleteDialogParams>(
BulkDeleteDialogComponent,
config
);
};
@Component({
selector: "vault-bulk-delete-dialog",
templateUrl: "bulk-delete-dialog.component.html",
})
export class BulkDeleteDialogComponent {
cipherIds: string[];
collectionIds: string[];
permanent = false;
organization: Organization;
constructor(
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
private dialogRef: DialogRef<BulkDeleteDialogResult>,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private apiService: ApiService
) {
this.cipherIds = params.cipherIds ?? [];
this.collectionIds = params.collectionIds ?? [];
this.permanent = params.permanent;
this.organization = params.organization;
}
protected async cancel() {
this.close(BulkDeleteDialogResult.Canceled);
}
protected submit = async () => {
const deletePromises: Promise<void>[] = [];
if (this.cipherIds.length) {
if (!this.organization || !this.organization.canEditAnyCollection) {
deletePromises.push(this.deleteCiphers());
} else {
deletePromises.push(this.deleteCiphersAdmin());
}
}
if (this.collectionIds.length && this.organization) {
deletePromises.push(this.deleteCollections());
}
await Promise.all(deletePromises);
if (this.cipherIds.length) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.permanent ? "permanentlyDeletedItems" : "deletedItems")
);
}
if (this.collectionIds.length) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollections")
);
}
this.close(BulkDeleteDialogResult.Deleted);
};
private async deleteCiphers(): Promise<any> {
if (this.permanent) {
await this.cipherService.deleteManyWithServer(this.cipherIds);
} else {
await this.cipherService.softDeleteManyWithServer(this.cipherIds);
}
}
private async deleteCiphersAdmin(): Promise<any> {
const deleteRequest = new CipherBulkDeleteRequest(this.cipherIds, this.organization.id);
if (this.permanent) {
return await this.apiService.deleteManyCiphersAdmin(deleteRequest);
} else {
return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
}
}
private async deleteCollections(): Promise<any> {
if (!this.organization.canDeleteAssignedCollections) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("missingPermissions")
);
return;
}
const deleteRequest = new CollectionBulkDeleteRequest(this.collectionIds, this.organization.id);
return await this.apiService.deleteManyCollections(deleteRequest);
}
private close(result: BulkDeleteDialogResult) {
this.dialogRef.close(result);
}
}

View File

@@ -0,0 +1,25 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../shared";
import { BulkDeleteDialogComponent } from "./bulk-delete-dialog/bulk-delete-dialog.component";
import { BulkMoveDialogComponent } from "./bulk-move-dialog/bulk-move-dialog.component";
import { BulkRestoreDialogComponent } from "./bulk-restore-dialog/bulk-restore-dialog.component";
import { BulkShareDialogComponent } from "./bulk-share-dialog/bulk-share-dialog.component";
@NgModule({
imports: [SharedModule],
declarations: [
BulkDeleteDialogComponent,
BulkMoveDialogComponent,
BulkRestoreDialogComponent,
BulkShareDialogComponent,
],
exports: [
BulkDeleteDialogComponent,
BulkMoveDialogComponent,
BulkRestoreDialogComponent,
BulkShareDialogComponent,
],
})
export class BulkDialogsModule {}

View File

@@ -0,0 +1,24 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="small">
<span bitDialogTitle>
{{ "moveSelected" | i18n }}
</span>
<span bitDialogContent>
<p>{{ "moveSelectedItemsDesc" | i18n: cipherIds.length }}</p>
<bit-form-field>
<bit-label for="folder">{{ "folder" | i18n }}</bit-label>
<select bitInput formControlName="folderId">
<option *ngFor="let f of folders$ | async" [ngValue]="f.id">{{ f.name }}</option>
</select>
</bit-form-field>
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton bitFormButton type="submit" buttonType="primary">
{{ "save" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,85 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, Observable } from "rxjs";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { DialogService } from "@bitwarden/components";
export interface BulkMoveDialogParams {
cipherIds?: string[];
}
export enum BulkMoveDialogResult {
Moved = "moved",
Canceled = "canceled",
}
/**
* Strongly typed helper to open a BulkMoveDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openBulkMoveDialog = (
dialogService: DialogService,
config: DialogConfig<BulkMoveDialogParams>
) => {
return dialogService.open<BulkMoveDialogResult, BulkMoveDialogParams>(
BulkMoveDialogComponent,
config
);
};
@Component({
selector: "vault-bulk-move-dialog",
templateUrl: "bulk-move-dialog.component.html",
})
export class BulkMoveDialogComponent implements OnInit {
cipherIds: string[] = [];
formGroup = this.formBuilder.group({
folderId: ["", [Validators.required]],
});
folders$: Observable<FolderView[]>;
constructor(
@Inject(DIALOG_DATA) params: BulkMoveDialogParams,
private dialogRef: DialogRef<BulkMoveDialogResult>,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private folderService: FolderService,
private formBuilder: FormBuilder
) {
this.cipherIds = params.cipherIds ?? [];
}
async ngOnInit() {
this.folders$ = this.folderService.folderViews$;
this.formGroup.patchValue({
folderId: (await firstValueFrom(this.folders$))[0].id,
});
}
protected cancel() {
this.close(BulkMoveDialogResult.Canceled);
}
protected submit = async () => {
if (this.formGroup.invalid) {
return;
}
await this.cipherService.moveManyWithServer(this.cipherIds, this.formGroup.value.folderId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("movedItems"));
this.close(BulkMoveDialogResult.Moved);
};
private close(result: BulkMoveDialogResult) {
this.dialogRef.close(result);
}
}

View File

@@ -0,0 +1,14 @@
<bit-simple-dialog>
<span bitDialogTitle>
{{ "restoreSelected" | i18n }}
</span>
<span bitDialogContent>
{{ "restoreSelectedItemsDesc" | i18n: cipherIds.length }}
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton type="submit" buttonType="primary" [bitAction]="submit">
{{ "restore" | i18n }}
</button>
<button bitButton type="button" (click)="cancel()">{{ "cancel" | i18n }}</button>
</div>
</bit-simple-dialog>

View File

@@ -0,0 +1,63 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { DialogService } from "@bitwarden/components";
export interface BulkRestoreDialogParams {
cipherIds: string[];
}
export enum BulkRestoreDialogResult {
Restored = "restored",
Canceled = "canceled",
}
/**
* Strongly typed helper to open a BulkRestoreDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openBulkRestoreDialog = (
dialogService: DialogService,
config: DialogConfig<BulkRestoreDialogParams>
) => {
return dialogService.open<BulkRestoreDialogResult, BulkRestoreDialogParams>(
BulkRestoreDialogComponent,
config
);
};
@Component({
selector: "vault-bulk-restore-dialog",
templateUrl: "bulk-restore-dialog.component.html",
})
export class BulkRestoreDialogComponent {
cipherIds: string[];
constructor(
@Inject(DIALOG_DATA) params: BulkRestoreDialogParams,
private dialogRef: DialogRef<BulkRestoreDialogResult>,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService
) {
this.cipherIds = params.cipherIds ?? [];
}
submit = async () => {
await this.cipherService.restoreManyWithServer(this.cipherIds);
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItems"));
this.close(BulkRestoreDialogResult.Restored);
};
protected cancel() {
this.close(BulkRestoreDialogResult.Canceled);
}
private close(result: BulkRestoreDialogResult) {
this.dialogRef.close(result);
}
}

View File

@@ -0,0 +1,73 @@
<bit-dialog>
<span bitDialogTitle>
{{ "moveSelectedToOrg" | i18n }}
</span>
<span bitDialogContent>
<p>{{ "moveManyToOrgDesc" | i18n }}</p>
<p>
{{
"moveSelectedItemsCountDesc"
| i18n: this.ciphers.length:shareableCiphers.length:nonShareableCount
}}
</p>
<bit-form-field>
<bit-label for="organization">{{ "organization" | i18n }}</bit-label>
<select
bitInput
[(ngModel)]="organizationId"
id="organization"
(change)="filterCollections()"
>
<option *ngFor="let o of organizations" [ngValue]="o.id">{{ o.name }}</option>
</select>
</bit-form-field>
<div class="d-flex">
<label class="tw-mb-1 tw-block tw-font-semibold tw-text-main">{{
"collections" | i18n
}}</label>
<div class="tw-ml-auto tw-flex tw-gap-2" *ngIf="collections && collections.length">
<button bitLink type="button" (click)="selectAll(true)" class="tw-px-2">
{{ "selectAll" | i18n }}
</button>
<button bitLink type="button" (click)="selectAll(false)" class="tw-px-2">
{{ "unselectAll" | i18n }}
</button>
</div>
</div>
<div *ngIf="!collections || !collections.length">
{{ "noCollectionsInList" | i18n }}
</div>
<table
class="table table-hover table-list mb-0"
*ngIf="collections && collections.length"
id="collections"
>
<tbody>
<tr *ngFor="let c of collections; let i = index" (click)="check(c)">
<td class="table-list-checkbox">
<input
bitInput
type="checkbox"
[(ngModel)]="c.checked"
name="Collection[{{ i }}].Checked"
attr.aria-label="Check {{ c.name }}"
appStopProp
/>
</td>
<td>
{{ c.name }}
</td>
</tr>
</tbody>
</table>
</span>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton type="submit" buttonType="primary" [bitAction]="submit">
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>

View File

@@ -1,4 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
@@ -10,32 +11,61 @@ import { Organization } from "@bitwarden/common/models/domain/organization";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { Checkable, isChecked } from "@bitwarden/common/types/checkable";
import { DialogService } from "@bitwarden/components";
export interface BulkShareDialogParams {
ciphers: CipherView[];
organizationId?: string;
}
export enum BulkShareDialogResult {
Shared = "shared",
Canceled = "canceled",
}
/**
* Strongly typed helper to open a BulkShareDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openBulkShareDialog = (
dialogService: DialogService,
config: DialogConfig<BulkShareDialogParams>
) => {
return dialogService.open<BulkShareDialogResult, BulkShareDialogParams>(
BulkShareDialogComponent,
config
);
};
@Component({
selector: "app-vault-bulk-share",
templateUrl: "bulk-share.component.html",
selector: "vault-bulk-share-dialog",
templateUrl: "bulk-share-dialog.component.html",
})
export class BulkShareComponent implements OnInit {
@Input() ciphers: CipherView[] = [];
@Input() organizationId: string;
@Output() onShared = new EventEmitter();
export class BulkShareDialogComponent implements OnInit {
ciphers: CipherView[] = [];
organizationId: string;
nonShareableCount = 0;
collections: Checkable<CollectionView>[] = [];
organizations: Organization[] = [];
shareableCiphers: CipherView[] = [];
formPromise: Promise<void>;
private writeableCollections: CollectionView[] = [];
constructor(
@Inject(DIALOG_DATA) params: BulkShareDialogParams,
private dialogRef: DialogRef<BulkShareDialogResult>,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private collectionService: CollectionService,
private organizationService: OrganizationService,
private logService: LogService
) {}
) {
this.ciphers = params.ciphers ?? [];
this.organizationId = params.organizationId;
}
async ngOnInit() {
this.shareableCiphers = this.ciphers.filter(
@@ -66,16 +96,14 @@ export class BulkShareComponent implements OnInit {
}
}
async submit() {
submit = async () => {
const checkedCollectionIds = this.collections.filter(isChecked).map((c) => c.id);
try {
this.formPromise = this.cipherService.shareManyWithServer(
await this.cipherService.shareManyWithServer(
this.shareableCiphers,
this.organizationId,
checkedCollectionIds
);
await this.formPromise;
this.onShared.emit();
const orgName =
this.organizations.find((o) => o.id === this.organizationId)?.name ??
this.i18nService.t("organization");
@@ -84,10 +112,11 @@ export class BulkShareComponent implements OnInit {
null,
this.i18nService.t("movedItemsToOrg", orgName)
);
this.close(BulkShareDialogResult.Shared);
} catch (e) {
this.logService.error(e);
}
}
};
check(c: Checkable<CollectionView>, select?: boolean) {
c.checked = select == null ? !c.checked : select;
@@ -112,4 +141,12 @@ export class BulkShareComponent implements OnInit {
}
return false;
}
protected cancel() {
this.close(BulkShareDialogResult.Canceled);
}
private close(result: BulkShareDialogResult) {
this.dialogRef.close(result);
}
}

View File

@@ -48,8 +48,3 @@
</button>
</div>
</div>
<ng-template #bulkDeleteTemplate></ng-template>
<ng-template #bulkRestoreTemplate></ng-template>
<ng-template #bulkMoveTemplate></ng-template>
<ng-template #bulkShareTemplate></ng-template>

View File

@@ -1,16 +1,29 @@
import { Component, Input, ViewChild, ViewContainerRef } from "@angular/core";
import { Component, Input } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { DialogService } from "@bitwarden/components";
import { BulkDeleteComponent } from "./bulk-delete.component";
import { BulkMoveComponent } from "./bulk-move.component";
import { BulkRestoreComponent } from "./bulk-restore.component";
import { BulkShareComponent } from "./bulk-share.component";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import {
BulkRestoreDialogResult,
openBulkRestoreDialog,
} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component";
import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { VaultItemsComponent } from "./vault-items.component";
@Component({
@@ -23,19 +36,10 @@ export class BulkActionsComponent {
@Input() deleted: boolean;
@Input() organization: Organization;
@ViewChild("bulkDeleteTemplate", { read: ViewContainerRef, static: true })
bulkDeleteModalRef: ViewContainerRef;
@ViewChild("bulkRestoreTemplate", { read: ViewContainerRef, static: true })
bulkRestoreModalRef: ViewContainerRef;
@ViewChild("bulkMoveTemplate", { read: ViewContainerRef, static: true })
bulkMoveModalRef: ViewContainerRef;
@ViewChild("bulkShareTemplate", { read: ViewContainerRef, static: true })
bulkShareModalRef: ViewContainerRef;
constructor(
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private modalService: ModalService,
private dialogService: DialogService,
private passwordRepromptService: PasswordRepromptService
) {}
@@ -44,8 +48,8 @@ export class BulkActionsComponent {
return;
}
const selectedIds = this.vaultItemsComponent.getSelectedIds();
if (selectedIds.length === 0) {
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
@@ -54,20 +58,18 @@ export class BulkActionsComponent {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkDeleteComponent,
this.bulkDeleteModalRef,
(comp) => {
comp.permanent = this.deleted;
comp.cipherIds = selectedIds;
comp.organization = this.organization;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onDeleted.subscribe(async () => {
modal.close();
await this.vaultItemsComponent.refresh();
});
}
);
const dialog = openBulkDeleteDialog(this.dialogService, {
data: {
permanent: this.deleted,
cipherIds: selectedCipherIds,
organization: this.organization,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
await this.vaultItemsComponent.refresh();
}
}
async bulkRestore() {
@@ -75,8 +77,8 @@ export class BulkActionsComponent {
return;
}
const selectedIds = this.vaultItemsComponent.getSelectedIds();
if (selectedIds.length === 0) {
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
@@ -85,18 +87,16 @@ export class BulkActionsComponent {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkRestoreComponent,
this.bulkRestoreModalRef,
(comp) => {
comp.cipherIds = selectedIds;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onRestored.subscribe(async () => {
modal.close();
await this.vaultItemsComponent.refresh();
});
}
);
const dialog = openBulkRestoreDialog(this.dialogService, {
data: {
cipherIds: selectedCipherIds,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkRestoreDialogResult.Restored) {
this.vaultItemsComponent.refresh();
}
}
async bulkShare() {
@@ -104,7 +104,7 @@ export class BulkActionsComponent {
return;
}
const selectedCiphers = this.vaultItemsComponent.getSelected();
const selectedCiphers = this.vaultItemsComponent.selectedCiphers;
if (selectedCiphers.length === 0) {
this.platformUtilsService.showToast(
"error",
@@ -114,18 +114,12 @@ export class BulkActionsComponent {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkShareComponent,
this.bulkShareModalRef,
(comp) => {
comp.ciphers = selectedCiphers;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onShared.subscribe(async () => {
modal.close();
await this.vaultItemsComponent.refresh();
});
}
);
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } });
const result = await lastValueFrom(dialog.closed);
if (result === BulkShareDialogResult.Shared) {
this.vaultItemsComponent.refresh();
}
}
async bulkMove() {
@@ -133,8 +127,8 @@ export class BulkActionsComponent {
return;
}
const selectedIds = this.vaultItemsComponent.getSelectedIds();
if (selectedIds.length === 0) {
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
@@ -143,26 +137,22 @@ export class BulkActionsComponent {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkMoveComponent,
this.bulkMoveModalRef,
(comp) => {
comp.cipherIds = selectedIds;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
comp.onMoved.subscribe(async () => {
modal.close();
await this.vaultItemsComponent.refresh();
});
}
);
const dialog = openBulkMoveDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkMoveDialogResult.Moved) {
this.vaultItemsComponent.refresh();
}
}
selectAll(select: boolean) {
this.vaultItemsComponent.selectAll(select);
this.vaultItemsComponent.checkAll(select);
}
private async promptPassword() {
const selectedCiphers = this.vaultItemsComponent.getSelected();
const selectedCiphers = this.vaultItemsComponent.selectedCiphers;
const notProtected = !selectedCiphers.find(
(cipher) => cipher.reprompt !== CipherRepromptType.None
);

View File

@@ -1,39 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="deleteSelectedTitle">
<div class="modal-dialog modal-dialog-scrollable modal-sm" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="deleteSelectedTitle">
{{ (permanent ? "permanentlyDeleteSelected" : "deleteSelected") | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{{
(permanent ? "permanentlyDeleteSelectedItemsDesc" : "deleteSelectedItemsDesc")
| i18n: cipherIds.length
}}
</div>
<div class="modal-footer">
<button
appAutoFocus
type="submit"
class="btn btn-danger btn-submit"
[disabled]="form.loading"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ (permanent ? "permanentlyDelete" : "delete") | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -1,62 +0,0 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { CipherBulkDeleteRequest } from "@bitwarden/common/models/request/cipher-bulk-delete.request";
@Component({
selector: "app-vault-bulk-delete",
templateUrl: "bulk-delete.component.html",
})
export class BulkDeleteComponent {
@Input() cipherIds: string[] = [];
@Input() permanent = false;
@Input() organization: Organization;
@Output() onDeleted = new EventEmitter();
formPromise: Promise<any>;
constructor(
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private apiService: ApiService
) {}
async submit() {
if (!this.organization || !this.organization.canEditAnyCollection) {
await this.deleteCiphers();
} else {
await this.deleteCiphersAdmin();
}
await this.formPromise;
this.onDeleted.emit();
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.permanent ? "permanentlyDeletedItems" : "deletedItems")
);
}
private async deleteCiphers() {
if (this.permanent) {
this.formPromise = await this.cipherService.deleteManyWithServer(this.cipherIds);
} else {
this.formPromise = await this.cipherService.softDeleteManyWithServer(this.cipherIds);
}
}
private async deleteCiphersAdmin() {
const deleteRequest = new CipherBulkDeleteRequest(this.cipherIds, this.organization.id);
if (this.permanent) {
this.formPromise = await this.apiService.deleteManyCiphersAdmin(deleteRequest);
} else {
this.formPromise = await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
}
}
}

View File

@@ -1,37 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="moveSelectedTitle">
<div class="modal-dialog modal-dialog-scrollable modal-sm" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="moveSelectedTitle">
{{ "moveSelected" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ "moveSelectedItemsDesc" | i18n: cipherIds.length }}</p>
<div class="form-group">
<label for="folder">{{ "folder" | i18n }}</label>
<select id="folder" name="FolderId" [(ngModel)]="folderId" class="form-control">
<option *ngFor="let f of folders$ | async" [ngValue]="f.id">{{ f.name }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -1,40 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { FolderView } from "@bitwarden/common/models/view/folder.view";
@Component({
selector: "app-vault-bulk-move",
templateUrl: "bulk-move.component.html",
})
export class BulkMoveComponent implements OnInit {
@Input() cipherIds: string[] = [];
@Output() onMoved = new EventEmitter();
folderId: string = null;
folders$: Observable<FolderView[]>;
formPromise: Promise<any>;
constructor(
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private folderService: FolderService
) {}
async ngOnInit() {
this.folders$ = this.folderService.folderViews$;
this.folderId = (await firstValueFrom(this.folders$))[0].id;
}
async submit() {
this.formPromise = this.cipherService.moveManyWithServer(this.cipherIds, this.folderId);
await this.formPromise;
this.onMoved.emit();
this.platformUtilsService.showToast("success", null, this.i18nService.t("movedItems"));
}
}

View File

@@ -1,36 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="restoreSelectedTitle">
<div class="modal-dialog modal-dialog-scrollable modal-sm" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="restoreSelectedTitle">
{{ "restoreSelected" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{{ "restoreSelectedItemsDesc" | i18n: cipherIds.length }}
</div>
<div class="modal-footer">
<button
appAutoFocus
type="submit"
class="btn btn-primary btn-submit"
[disabled]="form.loading"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "restore" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -1,29 +0,0 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
@Component({
selector: "app-vault-bulk-restore",
templateUrl: "bulk-restore.component.html",
})
export class BulkRestoreComponent {
@Input() cipherIds: string[] = [];
@Output() onRestored = new EventEmitter();
formPromise: Promise<any>;
constructor(
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService
) {}
async submit() {
this.formPromise = this.cipherService.restoreManyWithServer(this.cipherIds);
await this.formPromise;
this.onRestored.emit();
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItems"));
}
}

View File

@@ -1,85 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="moveSelectedToOrgTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="moveSelectedToOrgTitle">
{{ "moveSelectedToOrg" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ "moveManyToOrgDesc" | i18n }}</p>
<p>
{{
"moveSelectedItemsCountDesc"
| i18n: this.ciphers.length:shareableCiphers.length:nonShareableCount
}}
</p>
<div class="form-group">
<label for="organization">{{ "organization" | i18n }}</label>
<select
id="organization"
name="OrganizationId"
[(ngModel)]="organizationId"
class="form-control"
(change)="filterCollections()"
>
<option *ngFor="let o of organizations" [ngValue]="o.id">{{ o.name }}</option>
</select>
</div>
<div class="d-flex">
<h3>{{ "collections" | i18n }}</h3>
<div class="ml-auto d-flex" *ngIf="collections && collections.length">
<button type="button" (click)="selectAll(true)" class="btn btn-link btn-sm py-0">
{{ "selectAll" | i18n }}
</button>
<button type="button" (click)="selectAll(false)" class="btn btn-link btn-sm py-0">
{{ "unselectAll" | i18n }}
</button>
</div>
</div>
<div *ngIf="!collections || !collections.length">
{{ "noCollectionsInList" | i18n }}
</div>
<table class="table table-hover table-list mb-0" *ngIf="collections && collections.length">
<tbody>
<tr *ngFor="let c of collections; let i = index" (click)="check(c)">
<td class="table-list-checkbox">
<input
type="checkbox"
[(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked"
appStopProp
/>
</td>
<td>
{{ c.name }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit manual"
[disabled]="form.loading || !canSave"
[ngClass]="{ loading: form.loading }"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from "@angular/core";
import { CollectionView } from "@bitwarden/common/src/models/view/collection.view";
@Pipe({
name: "collectionNameFromId",
pure: true,
})
export class GetCollectionNameFromIdPipe implements PipeTransform {
transform(value: string, collections: CollectionView[]) {
return collections.find((o) => o.id === value)?.name;
}
}

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from "@angular/core";
import { GroupView } from "../../organizations/core";
@Pipe({
name: "groupNameFromId",
pure: true,
})
export class GetGroupNameFromIdPipe implements PipeTransform {
transform(value: string, groups: GroupView[]) {
return groups.find((o) => o.id === value)?.name;
}
}

View File

@@ -1,9 +1,11 @@
import { NgModule } from "@angular/core";
import { GetCollectionNameFromIdPipe } from "./get-collection-name.pipe";
import { GetGroupNameFromIdPipe } from "./get-group-name.pipe";
import { GetOrgNameFromIdPipe } from "./get-organization-name.pipe";
@NgModule({
declarations: [GetOrgNameFromIdPipe],
exports: [GetOrgNameFromIdPipe],
declarations: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe],
exports: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe],
})
export class PipesModule {}

View File

@@ -93,9 +93,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
async ngOnInit(): Promise<void> {
this.filters = await this.buildAllFilters();
await this.applyTypeFilter(
(await firstValueFrom(this.filters?.typeFilter.data$)) as TreeNode<CipherTypeFilter>
);
this.activeFilter.selectedCipherTypeNode =
(await this.getDefaultFilter()) as TreeNode<CipherTypeFilter>;
this.isLoaded = true;
}

View File

@@ -20,6 +20,7 @@ export abstract class VaultFilterService {
folderTree$: Observable<TreeNode<FolderFilter>>;
collectionTree$: Observable<TreeNode<CollectionFilter>>;
reloadCollections: () => Promise<void>;
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
expandOrgFilter: () => Promise<void>;
setOrganizationFilter: (organization: Organization) => void;

View File

@@ -1,4 +1,4 @@
import { Injectable, OnDestroy } from "@angular/core";
import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatestWith,
@@ -7,9 +7,7 @@ import {
Observable,
of,
ReplaySubject,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
@@ -26,6 +24,7 @@ import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { CollectionAdminView } from "../../../organizations/core";
import {
CipherTypeFilter,
CollectionFilter,
@@ -38,7 +37,7 @@ import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstracti
const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService implements VaultFilterServiceAbstraction, OnDestroy {
export class VaultFilterService implements VaultFilterServiceAbstraction {
protected _collapsedFilterNodes = new BehaviorSubject<Set<string>>(null);
collapsedFilterNodes$: Observable<Set<string>> = this._collapsedFilterNodes.pipe(
switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes()))
@@ -49,24 +48,31 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
switchMap((orgs) => this.buildOrganizationTree(orgs))
);
protected _filteredFolders = new ReplaySubject<FolderView[]>(1);
filteredFolders$: Observable<FolderView[]> = this._filteredFolders.asObservable();
protected _filteredCollections = new ReplaySubject<CollectionView[]>(1);
filteredCollections$: Observable<CollectionView[]> = this._filteredCollections.asObservable();
protected _organizationFilter = new BehaviorSubject<Organization>(null);
filteredFolders$: Observable<FolderView[]> = this.folderService.folderViews$.pipe(
combineLatestWith(this._organizationFilter),
switchMap(([folders, org]) => {
return this.filterFolders(folders, org);
})
);
folderTree$: Observable<TreeNode<FolderFilter>> = this.filteredFolders$.pipe(
map((folders) => this.buildFolderTree(folders))
);
// TODO: Remove once collections is refactored with observables
// replace with collection service observable
private collectionViews$ = new ReplaySubject<CollectionView[]>(1);
filteredCollections$: Observable<CollectionView[]> = this.collectionViews$.pipe(
combineLatestWith(this._organizationFilter),
switchMap(([collections, org]) => {
return this.filterCollections(collections, org);
})
);
collectionTree$: Observable<TreeNode<CollectionFilter>> = this.filteredCollections$.pipe(
map((collections) => this.buildCollectionTree(collections))
);
protected _organizationFilter = new BehaviorSubject<Organization>(null);
protected destroy$: Subject<void> = new Subject<void>();
// TODO: Remove once collections is refactored with observables
protected collectionViews$ = new ReplaySubject<CollectionView[]>(1);
constructor(
protected stateService: StateService,
protected organizationService: OrganizationService,
@@ -75,43 +81,18 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
protected collectionService: CollectionService,
protected policyService: PolicyService,
protected i18nService: I18nService
) {
this.loadSubscriptions();
}
protected loadSubscriptions() {
this.folderService.folderViews$
.pipe(
combineLatestWith(this._organizationFilter),
switchMap(([folders, org]) => {
return this.filterFolders(folders, org);
}),
takeUntil(this.destroy$)
)
.subscribe(this._filteredFolders);
// TODO: Use collectionService once collections is refactored
this.collectionViews$
.pipe(
combineLatestWith(this._organizationFilter),
switchMap(([collections, org]) => {
return this.filterCollections(collections, org);
}),
takeUntil(this.destroy$)
)
.subscribe(this._filteredCollections);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
) {}
// TODO: Remove once collections is refactored with observables
async reloadCollections() {
this.collectionViews$.next(await this.collectionService.getAllDecrypted());
}
async getCollectionNodeFromTree(id: string) {
const collections = await firstValueFrom(this.collectionTree$);
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionFilter>;
}
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes));
this._collapsedFilterNodes.next(collapsedFilterNodes);
@@ -208,6 +189,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
collectionCopy.id = c.id;
collectionCopy.organizationId = c.organizationId;
collectionCopy.icon = "bwi-collection";
if (c instanceof CollectionAdminView) {
collectionCopy.groups = c.groups;
}
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
});

View File

@@ -4,8 +4,8 @@
class="toggle-button"
(click)="toggleCollapse(headerNode.node)"
[attr.aria-expanded]="!isCollapsed(headerNode.node)"
appA11yTitle="{{ 'toggleCollapse' | i18n }}: {{ headerNode.node.name | i18n }}"
aria-controls="sub-filters"
title="{{ 'toggleCollapse' | i18n }}"
>
<i
class="bwi bwi-fw"
@@ -15,6 +15,9 @@
</button>
<button
*ngIf="headerInfo.isSelectable"
appA11yTitle="{{ isOrganizationFilter ? 'vault' : ('filter' | i18n) }}: {{
headerNode.node.name | i18n
}}"
class="filter-button"
(click)="onFilterSelect(headerNode)"
>
@@ -55,8 +58,7 @@
<span class="filter-buttons">
<button
*ngIf="f.children.length"
title="{{ 'toggleCollapse' | i18n }}"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ f.node.name | i18n }}"
appA11yTitle="{{ 'toggleCollapse' | i18n }}: {{ f.node.name }}"
(click)="toggleCollapse(f.node)"
[attr.aria-expanded]="!isCollapsed(f.node)"
[attr.aria-controls]="f.node.name + '_children'"
@@ -73,7 +75,9 @@
</button>
<button
class="filter-button"
appA11yTitle="{{ 'vault' | i18n }}: {{ f.node.name | i18n }}"
appA11yTitle="{{ isOrganizationFilter ? 'vault' : ('filter' | i18n) }}: {{
f.node.name
}}"
[ngClass]="{ 'disabled-organization': isOrganizationFilter && !f.node.enabled }"
(click)="onFilterSelect(f)"
>

View File

@@ -1,12 +1,15 @@
import { CipherType } from "@bitwarden/common/src/enums/cipherType";
import { Organization } from "@bitwarden/common/src/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/src/models/domain/tree-node";
import { CollectionView } from "@bitwarden/common/src/models/view/collection.view";
import { FolderView } from "@bitwarden/common/src/models/view/folder.view";
import { CollectionAdminView } from "../../../../organizations/core";
export type CipherStatus = "all" | "favorites" | "trash" | CipherType;
export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string };
export type CollectionFilter = CollectionView & { icon: string };
export type CollectionFilter = CollectionAdminView & {
icon: string;
};
export type FolderFilter = FolderView & { icon: string };
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };

View File

@@ -1,21 +1,162 @@
<ng-container *ngIf="isPaging() ? pagedCiphers : ciphers as filteredCiphers">
<table
class="table table-hover table-list table-ciphers"
*ngIf="filteredCiphers.length"
<ng-container>
<bit-table
*ngIf="filteredCiphers.length || filteredCollections.length"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let c of filteredCiphers">
<td (click)="checkCipher(c)" class="table-list-checkbox">
<ng-container header>
<tr>
<th bitCell class="tw-min-w-fit" colspan="2">
<input
class="tw-mr-2"
type="checkbox"
id="checkAll"
(change)="checkAll($any($event.target).checked)"
[(ngModel)]="isAllChecked"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="checkAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell class="tw-w-1/2">{{ "name" | i18n }}</th>
<th bitCell class="tw-w-1/2">
<ng-container *ngIf="!organization">{{ "owner" | i18n }}</ng-container>
<ng-container *ngIf="organization">
{{ (activeFilter.selectedCollectionNode ? "groups" : "collections") | i18n }}
</ng-container>
</th>
<th bitCell class="tw-min-w-fit">
<button
[bitMenuTriggerFor]="headerMenu"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<ng-container>
<button
class="dropdown-item"
appStopClick
(click)="bulkMove()"
*ngIf="!activeFilter.isDeleted && !organization"
>
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }}
</button>
<button
class="dropdown-item"
appStopClick
(click)="bulkShare()"
*ngIf="!activeFilter.isDeleted && !organization"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveSelectedToOrg" | i18n }}
</button>
<button class="dropdown-item" (click)="bulkRestore()" *ngIf="activeFilter.isDeleted">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restoreSelected" | i18n }}
</button>
<button class="dropdown-item text-danger" (click)="bulkDelete()">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{
(activeFilter.isDeleted ? "permanentlyDeleteSelected" : "deleteSelected") | i18n
}}
</button>
</ng-container>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let col of filteredCollections">
<td bitCell (click)="selectRow(col)">
<input
*ngIf="organization && col.node.id !== null"
class="tw-cursor-pointer"
type="checkbox"
[(ngModel)]="$any(col).checked"
appStopProp
/>
</td>
<td bitCell (click)="selectRow(col)">
<div class="icon" aria-hidden="true">
<i class="bwi bwi-fw bwi-lg bwi-collection"></i>
</div>
</td>
<td bitCell class="tw-font-bold" (click)="selectRow(col)">
<button bitLink linkType="secondary" (click)="navigateCollection(col)">
{{ col.node.name }}
</button>
</td>
<td bitCell (click)="selectRow(col)">
<ng-container *ngIf="!organization">
<app-org-badge
organizationName="{{ col.node.organizationId | orgNameFromId: organizations }}"
[profileName]="profileName"
(onOrganizationClicked)="onOrganizationClicked(col.node.organizationId)"
>
</app-org-badge>
</ng-container>
<ng-container *ngIf="organization && activeFilter.selectedCollectionNode">
<app-group-badge
*ngIf="col.node.groups"
[selectedGroups]="col.node.groups"
[allGroups]="groups"
></app-group-badge>
</ng-container>
</td>
<td bitCell>
<button
*ngIf="organization && col.node.id !== null"
[bitMenuTriggerFor]="collectionOptions"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #collectionOptions>
<button
*ngIf="organization?.canEditAssignedCollections || organization?.canEditAnyCollection"
bitMenuItem
(click)="editCollection(col.node, 'info')"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
*ngIf="organization?.canEditAssignedCollections || organization?.canEditAnyCollection"
bitMenuItem
(click)="editCollection(col.node, 'access')"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
*ngIf="
organization?.canDeleteAssignedCollections || organization?.canDeleteAnyCollection
"
bitMenuItem
(click)="deleteCollection(col.node)"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
<tr bitRow *ngFor="let c of filteredCiphers">
<td bitCell (click)="selectRow(c)">
<input type="checkbox" [(ngModel)]="$any(c).checked" appStopProp />
</td>
<td (click)="checkCipher(c)" class="table-list-icon">
<td bitCell (click)="selectRow(c)">
<app-vault-icon [cipher]="c"></app-vault-icon>
</td>
<td (click)="checkCipher(c)" class="reduced-lh wrap">
<td bitCell (click)="selectRow(c)">
<a
appStopProp
[routerLink]="[]"
@@ -45,23 +186,31 @@
<br />
<small appStopProp>{{ c.subTitle }}</small>
</td>
<td *ngIf="organizations.length > 0 && !organization" class="tw-w-28">
<app-org-badge
organizationName="{{ c.organizationId | orgNameFromId: organizations }}"
profileName="{{ profileName }}"
(onOrganizationClicked)="onOrganizationClicked(c.organizationId)"
>
</app-org-badge>
<td bitCell>
<ng-container *ngIf="!organization">
<app-org-badge
organizationName="{{ c.organizationId | orgNameFromId: organizations }}"
profileName="{{ profileName }}"
(onOrganizationClicked)="onOrganizationClicked(c.organizationId)"
>
</app-org-badge>
</ng-container>
<ng-container *ngIf="organization && !activeFilter.selectedCollectionNode">
<app-collection-badge
*ngIf="c.collectionIds"
[collectionIds]="c.collectionIds"
[collections]="vaultFilterService.filteredCollections$ | async"
></app-collection-badge>
</ng-container>
</td>
<td class="table-list-options">
<td bitCell>
<button
[bitMenuTriggerFor]="cipherOptions"
class="tw-border-none tw-bg-transparent tw-text-main"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-ellipsis-v bwi-lg" aria-hidden="true"></i>
</button>
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="c.type === cipherType.Login && !c.isDeleted">
<button bitMenuItem (click)="copy(c, c.login.username, 'username', 'Username')">
@@ -109,7 +258,11 @@
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveToOrganization" | i18n }}
</button>
<button bitMenuItem *ngIf="c.organizationId && !c.isDeleted" (click)="collections(c)">
<button
bitMenuItem
*ngIf="c.organizationId && !c.isDeleted"
(click)="editCipherCollections(c)"
>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collections" | i18n }}
</button>
@@ -121,7 +274,7 @@
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button bitMenuItem (click)="delete(c)">
<button bitMenuItem (click)="deleteCipher(c)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (c.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
@@ -130,9 +283,9 @@
</bit-menu>
</td>
</tr>
</tbody>
</table>
<div class="no-items" *ngIf="!filteredCiphers.length">
</ng-container>
</bit-table>
<div class="no-items" *ngIf="!filteredCiphers.length && !filteredCollections.length">
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"

View File

@@ -1,6 +1,8 @@
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/components/vault-items.component";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -16,46 +18,103 @@ import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { EventType } from "@bitwarden/common/enums/eventType";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { Icons } from "@bitwarden/components";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { DialogService, Icons } from "@bitwarden/components";
import { GroupView } from "../organizations/core";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import {
BulkRestoreDialogResult,
openBulkRestoreDialog,
} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component";
import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "./vault-filter/shared/models/vault-filter.type";
const MaxCheckedCount = 500;
export type VaultItemRow = (CipherView | TreeNode<CollectionFilter>) & { checked?: boolean };
@Component({
selector: "app-vault-items",
templateUrl: "vault-items.component.html",
})
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
@Input() showAddNew = true;
@Input() activeFilter: VaultFilter;
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
@Output() onAttachmentsClicked = new EventEmitter<CipherView>();
@Output() onShareClicked = new EventEmitter<CipherView>();
@Output() onCollectionsClicked = new EventEmitter<CipherView>();
@Output() onEditCipherCollectionsClicked = new EventEmitter<CipherView>();
@Output() onCloneClicked = new EventEmitter<CipherView>();
@Output() onOrganzationBadgeClicked = new EventEmitter<string>();
pagedCiphers: CipherView[] = [];
pageSize = 200;
cipherType = CipherType;
actionPromise: Promise<any>;
userHasPremiumAccess = false;
organizations: Organization[] = [];
profileName: string;
noItemIcon = Icons.Search;
groups: GroupView[] = [];
private didScroll = false;
private pagedCiphersCount = 0;
private refreshing = false;
protected pageSizeLimit = 200;
protected isAllChecked = false;
protected didScroll = false;
protected currentPagedCiphersCount = 0;
protected currentPagedCollectionsCount = 0;
protected refreshing = false;
protected pagedCiphers: CipherView[] = [];
protected pagedCollections: TreeNode<CollectionFilter>[] = [];
protected searchedCollections: TreeNode<CollectionFilter>[] = [];
get collections(): TreeNode<CollectionFilter>[] {
return this.activeFilter?.selectedCollectionNode?.children ?? [];
}
get filteredCollections(): TreeNode<CollectionFilter>[] {
if (this.isPaging()) {
return this.pagedCollections;
}
if (this.searchService.isSearchable(this.searchText)) {
return this.searchedCollections;
}
return this.collections;
}
get filteredCiphers(): CipherView[] {
return this.isPaging() ? this.pagedCiphers : this.ciphers;
}
constructor(
searchService: SearchService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected vaultFilterService: VaultFilterService,
protected cipherService: CipherService,
protected eventCollectionService: EventCollectionService,
protected totpService: TotpService,
protected stateService: StateService,
protected passwordRepromptService: PasswordRepromptService,
private logService: LogService,
protected dialogService: DialogService,
protected logService: LogService,
private searchPipe: SearchPipe,
private organizationService: OrganizationService,
private tokenService: TokenService
) {
@@ -63,36 +122,30 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
}
ngOnDestroy() {
this.selectAll(false);
this.checkAll(false);
}
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
this.checkAll(false);
this.isAllChecked = false;
this.pagedCollections = [];
if (!this.refreshing && this.isPaging()) {
this.currentPagedCollectionsCount = 0;
this.currentPagedCiphersCount = 0;
}
await super.applyFilter(filter);
}
// load() is called after the page loads and the first sync has completed.
// Do not use ngOnInit() for anything that requires sync data.
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
await super.load(filter, deleted);
this.updateSearchedCollections(this.collections);
this.profileName = await this.tokenService.getName();
this.organizations = await this.organizationService.getAll();
this.userHasPremiumAccess = await this.stateService.getCanAccessPremium();
}
loadMore() {
if (this.ciphers.length <= this.pageSize) {
return;
}
const pagedLength = this.pagedCiphers.length;
let pagedSize = this.pageSize;
if (this.refreshing && pagedLength === 0 && this.pagedCiphersCount > this.pageSize) {
pagedSize = this.pagedCiphersCount;
}
if (this.ciphers.length > pagedLength) {
this.pagedCiphers = this.pagedCiphers.concat(
this.ciphers.slice(pagedLength, pagedLength + pagedSize)
);
}
this.pagedCiphersCount = this.pagedCiphers.length;
this.didScroll = this.pagedCiphers.length > this.pageSize;
}
async refresh() {
try {
this.refreshing = true;
@@ -102,15 +155,58 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
}
}
loadMore() {
// If we have less rows than the page size, we don't need to page anything
if (this.ciphers.length + (this.collections?.length || 0) <= this.pageSizeLimit) {
return;
}
let pageSpaceLeft = this.pageSizeLimit;
if (
this.refreshing &&
this.pagedCiphers.length + this.pagedCollections.length === 0 &&
this.currentPagedCiphersCount + this.currentPagedCollectionsCount > this.pageSizeLimit
) {
// When we refresh, we want to load the previous amount of items, not restart the paging
pageSpaceLeft = this.currentPagedCiphersCount + this.currentPagedCollectionsCount;
}
// if there are still collections to show
if (this.collections?.length > this.pagedCollections.length) {
const collectionsToAdd = this.collections.slice(
this.pagedCollections.length,
this.currentPagedCollectionsCount + pageSpaceLeft
);
this.pagedCollections = this.pagedCollections.concat(collectionsToAdd);
// set the current count to the new count of paged collections
this.currentPagedCollectionsCount = this.pagedCollections.length;
// subtract the available page size by the amount of collections we just added, default to 0 if negative
pageSpaceLeft =
collectionsToAdd.length > pageSpaceLeft ? 0 : pageSpaceLeft - collectionsToAdd.length;
}
// if we have room left to show ciphers and we have ciphers to show
if (pageSpaceLeft > 0 && this.ciphers.length > this.pagedCiphers.length) {
this.pagedCiphers = this.pagedCiphers.concat(
this.ciphers.slice(this.pagedCiphers.length, this.currentPagedCiphersCount + pageSpaceLeft)
);
// set the current count to the new count of paged ciphers
this.currentPagedCiphersCount = this.pagedCiphers.length;
}
// set a flag if we actually loaded the second page while paging
this.didScroll = this.pagedCiphers.length + this.pagedCollections.length > this.pageSizeLimit;
}
isPaging() {
const searching = this.isSearching();
if (searching && this.didScroll) {
this.resetPaging();
}
return !searching && this.ciphers.length > this.pageSize;
const totalRows =
this.ciphers.length + (this.activeFilter?.selectedCollectionNode?.children.length || 0);
return !searching && totalRows > this.pageSizeLimit;
}
async resetPaging() {
this.pagedCollections = [];
this.pagedCiphers = [];
this.loadMore();
}
@@ -121,6 +217,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
[this.filter, this.deletedFilter],
indexedCiphers
);
this.updateSearchedCollections(this.collections);
this.resetPaging();
}
@@ -142,8 +239,8 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
this.onShareClicked.emit(c);
}
collections(c: CipherView) {
this.onCollectionsClicked.emit(c);
editCipherCollections(c: CipherView) {
this.onEditCipherCollectionsClicked.emit(c);
}
async clone(c: CipherView) {
@@ -153,7 +250,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
this.onCloneClicked.emit(c);
}
async delete(c: CipherView): Promise<boolean> {
async deleteCipher(c: CipherView): Promise<boolean> {
if (!(await this.repromptCipher(c))) {
return;
}
@@ -175,7 +272,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
}
try {
this.actionPromise = this.deleteCipher(c.id, permanent);
this.actionPromise = this.deleteCipherWithServer(c.id, permanent);
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
@@ -189,6 +286,33 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
this.actionPromise = null;
}
async bulkDelete() {
if (!(await this.repromptCipher())) {
return;
}
const selectedIds = this.selectedCipherIds;
if (selectedIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkDeleteDialog(this.dialogService, {
data: { permanent: this.deleted, cipherIds: selectedIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkDeleteDialogResult.Deleted) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async restore(c: CipherView): Promise<boolean> {
if (this.actionPromise != null || !c.isDeleted) {
return;
@@ -215,6 +339,85 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
this.actionPromise = null;
}
async bulkRestore() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkRestoreDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkRestoreDialogResult.Restored) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async bulkShare() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCiphers = this.selectedCiphers;
if (selectedCiphers.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } });
const result = await lastValueFrom(dialog.closed);
if (result === BulkShareDialogResult.Shared) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async bulkMove() {
if (!(await this.repromptCipher())) {
return;
}
const selectedCipherIds = this.selectedCipherIds;
if (selectedCipherIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = openBulkMoveDialog(this.dialogService, {
data: { cipherIds: selectedCipherIds },
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkMoveDialogResult.Moved) {
this.actionPromise = this.refresh();
await this.actionPromise;
this.actionPromise = null;
}
}
async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) {
if (
this.passwordRepromptService.protectedFields().includes(aType) &&
@@ -250,30 +453,52 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
}
}
selectAll(select: boolean) {
selectRow(item: VaultItemRow) {
if (item instanceof CipherView) {
this.checkRow(item);
} else if (item instanceof TreeNode) {
this.navigateCollection(item);
}
}
navigateCollection(node: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.selectedCollectionNode = node;
this.activeFilterChanged.emit(filter);
}
checkAll(select: boolean) {
if (select) {
this.selectAll(false);
this.checkAll(false);
}
const selectCount =
select && this.ciphers.length > MaxCheckedCount ? MaxCheckedCount : this.ciphers.length;
const items: VaultItemRow[] = this.ciphers;
if (!items) {
return;
}
const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length;
for (let i = 0; i < selectCount; i++) {
this.checkCipher(this.ciphers[i], select);
this.checkRow(items[i], select);
}
}
checkCipher(c: CipherView, select?: boolean) {
(c as any).checked = select == null ? !(c as any).checked : select;
checkRow(item: VaultItemRow, select?: boolean) {
// Collections can't be managed in end user vault
if (!(item instanceof CipherView)) {
return;
}
item.checked = select ?? !item.checked;
}
getSelected(): CipherView[] {
if (this.ciphers == null) {
get selectedCiphers(): CipherView[] {
if (!this.ciphers) {
return [];
}
return this.ciphers.filter((c) => !!(c as any).checked);
return this.ciphers.filter((c) => !!(c as VaultItemRow).checked);
}
getSelectedIds(): string[] {
return this.getSelected().map((c) => c.id);
get selectedCipherIds(): string[] {
return this.selectedCiphers.map((c) => c.id);
}
displayTotpCopyButton(cipher: CipherView) {
@@ -296,7 +521,26 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
// TODO: This should be removed but is needed since we reuse the same template
}
protected deleteCipher(id: string, permanent: boolean) {
async deleteCollection(collection: CollectionView): Promise<void> {
// TODO: This should be removed but is needed since we reuse the same template
}
async editCollection(c: CollectionView, tab: "info" | "access"): Promise<void> {
// TODO: This should be removed but is needed since we reuse the same template
}
protected updateSearchedCollections(collections: TreeNode<CollectionFilter>[]) {
if (this.searchService.isSearchable(this.searchText)) {
this.searchedCollections = this.searchPipe.transform(
collections,
this.searchText,
(collection) => collection.node.name,
(collection) => collection.node.id
);
}
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id)
: this.cipherService.softDeleteWithServer(id);
@@ -306,10 +550,19 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
return c.hasOldAttachments && c.organizationId == null;
}
protected async repromptCipher(c: CipherView) {
return (
c.reprompt === CipherRepromptType.None ||
(await this.passwordRepromptService.showPasswordPrompt())
);
protected async repromptCipher(c?: CipherView) {
if (c) {
return (
c.reprompt === CipherRepromptType.None ||
(await this.passwordRepromptService.showPasswordPrompt())
);
} else {
const selectedCiphers = this.selectedCiphers;
const notProtected = !selectedCiphers.find(
(cipher) => cipher.reprompt !== CipherRepromptType.None
);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
}
}

View File

@@ -51,11 +51,13 @@
{{ trashCleanupWarning }}
</app-callout>
<app-vault-items
[activeFilter]="activeFilter"
(activeFilterChanged)="applyVaultFilter($event)"
(onCipherClicked)="editCipher($event)"
(onAttachmentsClicked)="editCipherAttachments($event)"
(onAddCipher)="addCipher()"
(onShareClicked)="shareCipher($event)"
(onCollectionsClicked)="editCipherCollections($event)"
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
(onCloneClicked)="cloneCipher($event)"
(onOrganzationBadgeClicked)="applyOrganizationFilter($event)"
>

View File

@@ -8,8 +8,8 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { firstValueFrom, Subject } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
@@ -67,6 +67,7 @@ export class VaultComponent implements OnInit, OnDestroy {
showPremiumCallout = false;
trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter();
private destroy$ = new Subject<void>();
constructor(
private syncService: SyncService,
@@ -97,63 +98,72 @@ export class VaultComponent implements OnInit, OnDestroy {
: "trashCleanupWarning"
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => {
await this.syncService.fullSync(false);
const canAccessPremium = await this.stateService.getCanAccessPremium();
this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
this.route.queryParams
.pipe(
first(),
switchMap(async (params: Params) => {
await this.syncService.fullSync(false);
await this.vaultFilterService.reloadCollections();
await this.vaultItemsComponent.reload();
await this.vaultFilterService.reloadCollections();
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
const canAccessPremium = await this.stateService.getCanAccessPremium();
this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
const cipherView = new CipherView();
cipherView.id = cipherId;
if (params.action === "clone") {
await this.cloneCipher(cipherView);
} else if (params.action === "edit") {
await this.editCipher(cipherView);
}
}
await this.vaultItemsComponent.reload();
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.subscribe(async (params) => {
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
if ((await this.cipherService.get(cipherId)) != null) {
this.editCipherId(cipherId);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher")
);
this.router.navigate([], {
queryParams: { itemId: null, cipherId: null },
queryParamsHandling: "merge",
});
const cipherId = getCipherIdFromParams(params);
if (!cipherId) {
return;
}
}
});
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await Promise.all([
this.vaultFilterService.reloadCollections(),
this.vaultItemsComponent.load(this.vaultItemsComponent.filter),
]);
this.changeDetectorRef.detectChanges();
}
break;
const cipherView = new CipherView();
cipherView.id = cipherId;
if (params.action === "clone") {
await this.cloneCipher(cipherView);
} else if (params.action === "edit") {
await this.editCipher(cipherView);
}
});
}),
takeUntil(this.destroy$)
)
.subscribe();
this.route.queryParams
.pipe(
switchMap(async (params) => {
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
if ((await this.cipherService.get(cipherId)) != null) {
this.editCipherId(cipherId);
} else {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher")
);
this.router.navigate([], {
queryParams: { itemId: null, cipherId: null },
queryParamsHandling: "merge",
});
}
}
}),
takeUntil(this.destroy$)
)
.subscribe();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await Promise.all([
this.vaultFilterService.reloadCollections(),
this.vaultItemsComponent.load(this.vaultItemsComponent.filter),
]);
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
}
@@ -173,6 +183,8 @@ export class VaultComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.destroy$.next();
this.destroy$.complete();
}
async applyVaultFilter(filter: VaultFilter) {

View File

@@ -1,7 +1,10 @@
import { NgModule } from "@angular/core";
import { CollectionBadgeModule } from "../organizations/vault/collection-badge/collection-badge.module";
import { GroupBadgeModule } from "../organizations/vault/group-badge/group-badge.module";
import { SharedModule, LooseComponentsModule } from "../shared";
import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module";
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
import { PipesModule } from "./pipes/pipes.module";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
@@ -14,9 +17,12 @@ import { VaultComponent } from "./vault.component";
VaultFilterModule,
VaultRoutingModule,
OrganizationBadgeModule,
GroupBadgeModule,
CollectionBadgeModule,
PipesModule,
SharedModule,
LooseComponentsModule,
BulkDialogsModule,
],
declarations: [VaultComponent, VaultItemsComponent],
exports: [VaultComponent],

View File

@@ -460,6 +460,9 @@
"vaultItems": {
"message": "Vault items"
},
"filter": {
"message": "Filter"
},
"moveSelectedToOrg": {
"message": "Move selected to organization"
},
@@ -566,6 +569,12 @@
"deletedFolder": {
"message": "Folder deleted"
},
"editInfo": {
"message": "Edit info"
},
"access": {
"message": "Access"
},
"loggedOut": {
"message": "Logged out"
},
@@ -871,7 +880,7 @@
"message": "Edit the collections that this item is being shared with. Only organization users with access to these collections will be able to see this item."
},
"deleteSelectedItemsDesc": {
"message": "You have selected $COUNT$ item(s) to delete. Are you sure you want to delete all of these items?",
"message": "$COUNT$ item(s) will be sent to trash.",
"placeholders": {
"count": {
"content": "$1",
@@ -879,6 +888,18 @@
}
}
},
"deleteSelectedCollectionsDesc": {
"message": "$COUNT$ collection(s) will be permanently deleted.",
"placeholders": {
"count": {
"content": "$1",
"example": "150"
}
}
},
"deleteSelectedConfirmation": {
"message": "Are you sure you want to continue?"
},
"moveSelectedItemsDesc": {
"message": "Choose a folder that you would like to move the $COUNT$ selected item(s) to.",
"placeholders": {
@@ -2707,6 +2728,9 @@
}
}
},
"deletedCollections": {
"message": "Deleted collections"
},
"deletedCollectionId": {
"message": "Deleted collection $ID$.",
"placeholders": {
@@ -3689,7 +3713,7 @@
"message": "Restore items"
},
"restoreSelectedItemsDesc": {
"message": "You have selected $COUNT$ item(s) to restore. Are you sure you want to restore all of these items?",
"message": "You have selected $COUNT$ item(s) to restore. Are you sure you want to restore these items?",
"placeholders": {
"count": {
"content": "$1",

View File

@@ -121,7 +121,7 @@
<tbody>
<tr *ngFor="let u of searchedUsers">
<td (click)="checkUser(u)" class="table-list-checkbox">
<input type="checkbox" [(ngModel)]="u.checked" appStopProp />
<input type="checkbox" [(ngModel)]="$any(u).checked" appStopProp />
</td>
<td width="30">
<bit-avatar [text]="u | userName" [id]="u.userId" size="small"></bit-avatar>
@@ -137,7 +137,7 @@
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
</td>
<td>
<ng-container *ngIf="u.twoFactorEnabled">
<ng-container *ngIf="$any(u).twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"

View File

@@ -14,7 +14,6 @@ export class VaultItemsComponent {
loaded = false;
ciphers: CipherView[] = [];
searchText: string;
searchPlaceholder: string = null;
filter: (cipher: CipherView) => boolean = null;
deleted = false;
@@ -24,11 +23,18 @@ export class VaultItemsComponent {
protected searchPending = false;
private searchTimeout: any = null;
private _searchText: string = null;
get searchText() {
return this._searchText;
}
set searchText(value: string) {
this._searchText = value;
}
constructor(protected searchService: SearchService) {}
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
this.deleted = deleted || false;
this.deleted = deleted ?? false;
await this.applyFilter(filter);
this.loaded = true;
}

View File

@@ -1,16 +1,32 @@
import { Pipe, PipeTransform } from "@angular/core";
type PropertyValueFunction<T> = (item: T) => { toString: () => string };
@Pipe({
name: "search",
})
export class SearchPipe implements PipeTransform {
transform(
items: any[],
transform<T>(
items: T[],
searchText: string,
prop1?: string,
prop2?: string,
prop3?: string
): any[] {
prop1?: keyof T,
prop2?: keyof T,
prop3?: keyof T
): T[];
transform<T>(
items: T[],
searchText: string,
prop1?: PropertyValueFunction<T>,
prop2?: PropertyValueFunction<T>,
prop3?: PropertyValueFunction<T>
): T[];
transform<T>(
items: T[],
searchText: string,
prop1?: keyof T | PropertyValueFunction<T>,
prop2?: keyof T | PropertyValueFunction<T>,
prop3?: keyof T | PropertyValueFunction<T>
): T[] {
if (items == null || items.length === 0) {
return [];
}
@@ -21,27 +37,30 @@ export class SearchPipe implements PipeTransform {
searchText = searchText.trim().toLowerCase();
return items.filter((i) => {
if (
prop1 != null &&
i[prop1] != null &&
i[prop1].toString().toLowerCase().indexOf(searchText) > -1
) {
return true;
if (prop1 != null) {
const propValue = typeof prop1 === "function" ? prop1(i) : i[prop1];
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
return true;
}
}
if (
prop2 != null &&
i[prop2] != null &&
i[prop2].toString().toLowerCase().indexOf(searchText) > -1
) {
return true;
if (prop2 != null) {
const propValue = typeof prop2 === "function" ? prop2(i) : i[prop2];
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
return true;
}
}
if (
prop3 != null &&
i[prop3] != null &&
i[prop3].toString().toLowerCase().indexOf(searchText) > -1
) {
return true;
if (prop3 != null) {
const propValue = typeof prop3 === "function" ? prop3(i) : i[prop3];
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
return true;
}
}
return false;
});
}

View File

@@ -11,6 +11,7 @@ import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherShareRequest } from "../models/request/cipher-share.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request";
import { CollectionRequest } from "../models/request/collection.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
@@ -316,13 +317,16 @@ export abstract class ApiService {
) => Promise<AttachmentUploadDataResponse>;
postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise<any>;
getCollectionDetails: (
organizationId: string,
id: string
) => Promise<CollectionAccessDetailsResponse>;
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;
getCollectionAccessDetails: (
organizationId: string,
id: string
) => Promise<CollectionAccessDetailsResponse>;
getManyCollectionsWithAccessDetails: (
orgId: string
) => Promise<ListResponse<CollectionAccessDetailsResponse>>;
postCollection: (
organizationId: string,
request: CollectionRequest
@@ -338,6 +342,7 @@ export abstract class ApiService {
request: CollectionRequest
) => Promise<CollectionResponse>;
deleteCollection: (organizationId: string, id: string) => Promise<any>;
deleteManyCollections: (request: CollectionBulkDeleteRequest) => Promise<any>;
deleteCollectionUser: (
organizationId: string,
id: string,

View File

@@ -6,7 +6,7 @@ import { Organization } from "../../models/domain/organization";
import { I18nService } from "../i18n.service";
export function canAccessVaultTab(org: Organization): boolean {
return org.isManager;
return org.canViewAssignedCollections || org.canViewAllCollections || org.canManageGroups;
}
export function canAccessSettingsTab(org: Organization): boolean {

View File

@@ -0,0 +1,84 @@
import { TreeNode } from "../models/domain/tree-node";
import { ServiceUtils } from "./serviceUtils";
describe("serviceUtils", () => {
type fakeObject = { id: string; name: string };
let nodeTree: TreeNode<fakeObject>[];
beforeEach(() => {
nodeTree = [
{
parent: null,
node: { id: "1", name: "1" },
children: [
{
parent: { id: "1", name: "1" },
node: { id: "1.1", name: "1.1" },
children: [
{
parent: { id: "1.1", name: "1.1" },
node: { id: "1.1.1", name: "1.1.1" },
children: [],
},
],
},
{
parent: { id: "1", name: "1" },
node: { id: "1.2", name: "1.2" },
children: [],
},
],
},
{
parent: null,
node: { id: "2", name: "2" },
children: [
{
parent: { id: "2", name: "2" },
node: { id: "2.1", name: "2.1" },
children: [],
},
],
},
{
parent: null,
node: { id: "3", name: "3" },
children: [],
},
];
});
describe("nestedTraverse", () => {
it("should traverse a tree and add a node at the correct position given a valid path", () => {
const nodeToBeAdded: fakeObject = { id: "1.2.1", name: "1.2.1" };
const path = ["1", "1.2", "1.2.1"];
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
expect(nodeTree[0].children[1].children[0].node).toEqual(nodeToBeAdded);
});
it("should combine the path for missing nodes and use as the added node name given an invalid path", () => {
const nodeToBeAdded: fakeObject = { id: "blank", name: "blank" };
const path = ["3", "3.1", "3.1.1"];
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
expect(nodeTree[2].children[0].node.name).toEqual("3.1/3.1.1");
});
});
describe("getTreeNodeObject", () => {
it("should return a matching node given a single tree branch and a valid id", () => {
const id = "1.1.1";
const given = ServiceUtils.getTreeNodeObject(nodeTree[0], id);
expect(given.node.id).toEqual(id);
});
});
describe("getTreeNodeObjectFromList", () => {
it("should return a matching node given a list of branches and a valid id", () => {
const id = "1.1.1";
const given = ServiceUtils.getTreeNodeObjectFromList(nodeTree, id);
expect(given.node.id).toEqual(id);
});
});
});

View File

@@ -8,7 +8,7 @@ export class ServiceUtils {
* @param {string[]} parts - Array of strings that represent the path to the `obj` node
* @param {ITreeNodeObject} obj - The node to be added to the tree
* @param {ITreeNodeObject} parent - The parent node of the `obj` node
* @param {string} delimiter - The delimiter used to split the path string
* @param {string} delimiter - The delimiter used to split the path string, will be used to combine the path for missing nodes
*/
static nestedTraverse(
nodeTree: TreeNode<ITreeNodeObject>[],
@@ -22,18 +22,19 @@ export class ServiceUtils {
return;
}
const end = partIndex === parts.length - 1;
const partName = parts[partIndex];
const end: boolean = partIndex === parts.length - 1;
const partName: string = parts[partIndex];
for (let i = 0; i < nodeTree.length; i++) {
if (nodeTree[i].node.name !== parts[partIndex]) {
if (nodeTree[i].node.name !== partName) {
continue;
}
if (end && nodeTree[i].node.id !== obj.id) {
// Another node with the same name.
// Another node exists with the same name as the node being added
nodeTree.push(new TreeNode(obj, parent, partName));
return;
}
// Move down the tree to the next level
ServiceUtils.nestedTraverse(
nodeTree[i].children,
partIndex + 1,
@@ -45,12 +46,17 @@ export class ServiceUtils {
return;
}
// If there's no node here with the same name...
if (nodeTree.filter((n) => n.node.name === partName).length === 0) {
// And we're at the end of the path given, add the node
if (end) {
nodeTree.push(new TreeNode(obj, parent, partName));
return;
}
const newPartName = parts[partIndex] + delimiter + parts[partIndex + 1];
// And we're not at the end of the path, combine the current name with the next name
// 1, *1.2, 1.2.1 becomes
// 1, *1.2/1.2.1
const newPartName = partName + delimiter + parts[partIndex + 1];
ServiceUtils.nestedTraverse(
nodeTree,
0,

View File

@@ -0,0 +1,9 @@
export class CollectionBulkDeleteRequest {
ids: string[];
organizationId: string;
constructor(ids: string[], organizationId?: string) {
this.ids = ids == null ? [] : ids;
this.organizationId = organizationId;
}
}

View File

@@ -17,6 +17,7 @@ import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherShareRequest } from "../models/request/cipher-share.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request";
import { CollectionRequest } from "../models/request/collection.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
@@ -812,7 +813,7 @@ export class ApiService implements ApiServiceAbstraction {
// Collections APIs
async getCollectionDetails(
async getCollectionAccessDetails(
organizationId: string,
id: string
): Promise<CollectionAccessDetailsResponse> {
@@ -842,6 +843,19 @@ export class ApiService implements ApiServiceAbstraction {
return new ListResponse(r, CollectionResponse);
}
async getManyCollectionsWithAccessDetails(
organizationId: string
): Promise<ListResponse<CollectionAccessDetailsResponse>> {
const r = await this.send(
"GET",
"/organizations/" + organizationId + "/collections/details",
null,
true,
true
);
return new ListResponse(r, CollectionAccessDetailsResponse);
}
async getCollectionUsers(
organizationId: string,
id: string
@@ -909,6 +923,16 @@ export class ApiService implements ApiServiceAbstraction {
);
}
deleteManyCollections(request: CollectionBulkDeleteRequest): Promise<any> {
return this.send(
"DELETE",
"/organizations/" + request.organizationId + "/collections",
request,
true,
false
);
}
deleteCollectionUser(
organizationId: string,
id: string,

View File

@@ -56,7 +56,7 @@ const commonStyles = [
"before:-tw-inset-x-[0.1em]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring-2",
"focus-visible:before:tw-ring-2",
"focus-visible:before:tw-ring-text-contrast",
"focus-visible:tw-z-10",
];