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:
1
.github/whitelist-capital-letters.txt
vendored
1
.github/whitelist-capital-letters.txt
vendored
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
13
apps/web/src/app/vault/pipes/get-collection-name.pipe.ts
Normal file
13
apps/web/src/app/vault/pipes/get-collection-name.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app/vault/pipes/get-group-name.pipe.ts
Normal file
13
apps/web/src/app/vault/pipes/get-group-name.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
84
libs/common/src/misc/serviceUtils.spec.ts
Normal file
84
libs/common/src/misc/serviceUtils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class CollectionBulkDeleteRequest {
|
||||
ids: string[];
|
||||
organizationId: string;
|
||||
|
||||
constructor(ids: string[], organizationId?: string) {
|
||||
this.ids = ids == null ? [] : ids;
|
||||
this.organizationId = organizationId;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user