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/nodeUtils.ts
|
||||||
./libs/common/src/misc/linkedFieldOption.decorator.ts
|
./libs/common/src/misc/linkedFieldOption.decorator.ts
|
||||||
./libs/common/src/misc/serviceUtils.ts
|
./libs/common/src/misc/serviceUtils.ts
|
||||||
|
./libs/common/src/misc/serviceUtils.spec.ts
|
||||||
./libs/common/src/types/twoFactorResponse.ts
|
./libs/common/src/types/twoFactorResponse.ts
|
||||||
./libs/common/src/types/authResponse.ts
|
./libs/common/src/types/authResponse.ts
|
||||||
./libs/common/src/types/syncEventArgs.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.");
|
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);
|
const decCollection = new CollectionView(response);
|
||||||
decCollection.name = await this.cryptoService.decryptToUtf8(
|
decCollection.name = await this.cryptoService.decryptToUtf8(
|
||||||
new EncString(response.name),
|
new EncString(response.name),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStat
|
|||||||
import { ProviderUserType } from "@bitwarden/common/enums/providerUserType";
|
import { ProviderUserType } from "@bitwarden/common/enums/providerUserType";
|
||||||
import { Utils } from "@bitwarden/common/misc/utils";
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
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 { ProviderUserUserDetailsResponse } from "@bitwarden/common/models/response/provider/provider-user.response";
|
||||||
|
|
||||||
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
|
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
|
||||||
@@ -182,7 +181,7 @@ export abstract class BasePeopleComponent<
|
|||||||
this.didScroll = this.pagedUsers.length > this.pageSize;
|
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;
|
(user as any).checked = select == null ? !(user as any).checked : select;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
CollectionAccessDetailsResponse,
|
CollectionAccessDetailsResponse,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
} from "@bitwarden/common/models/response/collection.response";
|
} from "@bitwarden/common/models/response/collection.response";
|
||||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
|
||||||
|
|
||||||
import { CoreOrganizationModule } from "../core-organization.module";
|
import { CoreOrganizationModule } from "../core-organization.module";
|
||||||
import { CollectionAdminView } from "../views/collection-admin.view";
|
import { CollectionAdminView } from "../views/collection-admin.view";
|
||||||
@@ -18,8 +17,11 @@ import { CollectionAdminView } from "../views/collection-admin.view";
|
|||||||
export class CollectionAdminService {
|
export class CollectionAdminService {
|
||||||
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
|
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
|
||||||
|
|
||||||
async getAll(organizationId: string): Promise<CollectionView[]> {
|
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
|
||||||
const collectionResponse = await this.apiService.getCollections(organizationId);
|
const collectionResponse = await this.apiService.getManyCollectionsWithAccessDetails(
|
||||||
|
organizationId
|
||||||
|
);
|
||||||
|
|
||||||
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
|
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ export class CollectionAdminService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
collectionId: string
|
collectionId: string
|
||||||
): Promise<CollectionAdminView | undefined> {
|
): Promise<CollectionAdminView | undefined> {
|
||||||
const collectionResponse = await this.apiService.getCollectionDetails(
|
const collectionResponse = await this.apiService.getCollectionAccessDetails(
|
||||||
organizationId,
|
organizationId,
|
||||||
collectionId
|
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";
|
import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response";
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
[activeOrganization]="organization"
|
[activeOrganization]="organization"
|
||||||
></app-organization-switcher>
|
></app-organization-switcher>
|
||||||
<bit-tab-nav-bar class="-tw-mb-px">
|
<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">{{
|
<bit-tab-link *ngIf="canShowMembersTab(organization)" route="members">{{
|
||||||
"members" | i18n
|
"members" | i18n
|
||||||
}}</bit-tab-link>
|
}}</bit-tab-link>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
canAccessMembersTab,
|
canAccessMembersTab,
|
||||||
canAccessReportingTab,
|
canAccessReportingTab,
|
||||||
canAccessSettingsTab,
|
canAccessSettingsTab,
|
||||||
|
canAccessVaultTab,
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
OrganizationService,
|
OrganizationService,
|
||||||
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||||
@@ -44,6 +45,10 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this._destroy.complete();
|
this._destroy.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canShowVaultTab(organization: Organization): boolean {
|
||||||
|
return canAccessVaultTab(organization);
|
||||||
|
}
|
||||||
|
|
||||||
canShowSettingsTab(organization: Organization): boolean {
|
canShowSettingsTab(organization: Organization): boolean {
|
||||||
return canAccessSettingsTab(organization);
|
return canAccessSettingsTab(organization);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||||||
private updateSearchedGroups() {
|
private updateSearchedGroups() {
|
||||||
if (this.searchService.isSearchable(this.searchText)) {
|
if (this.searchService.isSearchable(this.searchText)) {
|
||||||
// Making use of the pipe in the component as we need know which groups where filtered
|
// 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>
|
<ng-container body>
|
||||||
<tr bitRow *ngFor="let u of searchedUsers" alignContent="middle">
|
<tr bitRow *ngFor="let u of searchedUsers" alignContent="middle">
|
||||||
<td bitCell (click)="checkUser(u)">
|
<td bitCell (click)="checkUser(u)">
|
||||||
<input type="checkbox" [(ngModel)]="u.checked" />
|
<input type="checkbox" [(ngModel)]="$any(u).checked" />
|
||||||
</td>
|
</td>
|
||||||
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
|
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
|
||||||
<div class="tw-flex tw-items-center">
|
<div class="tw-flex tw-items-center">
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="showEnrolledStatus(u)">
|
<ng-container *ngIf="showEnrolledStatus($any(u))">
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-key"
|
class="bwi bwi-key"
|
||||||
title="{{ 'enrolledPasswordReset' | i18n }}"
|
title="{{ 'enrolledPasswordReset' | i18n }}"
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
(click)="groups(u)"
|
(click)="groups($any(u))"
|
||||||
*ngIf="organization.useGroups"
|
*ngIf="organization.useGroups"
|
||||||
>
|
>
|
||||||
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
|
<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 { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
|
||||||
import {
|
import {
|
||||||
canAccessGroupsTab,
|
|
||||||
canAccessOrgAdmin,
|
canAccessOrgAdmin,
|
||||||
|
canAccessGroupsTab,
|
||||||
|
canAccessMembersTab,
|
||||||
|
canAccessVaultTab,
|
||||||
|
canAccessReportingTab,
|
||||||
|
canAccessSettingsTab,
|
||||||
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/models/domain/organization";
|
||||||
|
|
||||||
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
|
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
|
||||||
|
import { OrganizationRedirectGuard } from "./guards/org-redirect.guard";
|
||||||
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
|
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
|
||||||
import { CollectionsComponent } from "./manage/collections.component";
|
import { CollectionsComponent } from "./manage/collections.component";
|
||||||
import { GroupsComponent } from "./manage/groups.component";
|
import { GroupsComponent } from "./manage/groups.component";
|
||||||
@@ -23,7 +29,15 @@ const routes: Routes = [
|
|||||||
organizationPermissions: canAccessOrgAdmin,
|
organizationPermissions: canAccessOrgAdmin,
|
||||||
},
|
},
|
||||||
children: [
|
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",
|
path: "vault",
|
||||||
loadChildren: () => VaultModule,
|
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({
|
@NgModule({
|
||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
exports: [RouterModule],
|
exports: [RouterModule],
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<ng-container *ngIf="loading" #spinner>
|
<ng-container *ngIf="loading" #spinner>
|
||||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
|
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<bit-tab-group *ngIf="!loading">
|
<bit-tab-group *ngIf="!loading" [selectedIndex]="tabIndex">
|
||||||
<bit-tab label="{{ 'collectionInfo' | i18n }}">
|
<bit-tab label="{{ 'collectionInfo' | i18n }}">
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>{{ "name" | i18n }}</bit-label>
|
<bit-label>{{ "name" | i18n }}</bit-label>
|
||||||
|
|||||||
@@ -27,9 +27,15 @@ import {
|
|||||||
PermissionMode,
|
PermissionMode,
|
||||||
} from "../access-selector";
|
} from "../access-selector";
|
||||||
|
|
||||||
|
export enum CollectionDialogTabType {
|
||||||
|
Info = 0,
|
||||||
|
Access = 1,
|
||||||
|
}
|
||||||
|
|
||||||
export interface CollectionDialogParams {
|
export interface CollectionDialogParams {
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
initialTab?: CollectionDialogTabType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CollectionDialogResult {
|
export enum CollectionDialogResult {
|
||||||
@@ -45,6 +51,7 @@ export enum CollectionDialogResult {
|
|||||||
export class CollectionDialogComponent implements OnInit, OnDestroy {
|
export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
protected tabIndex: CollectionDialogTabType;
|
||||||
protected loading = true;
|
protected loading = true;
|
||||||
protected organization?: Organization;
|
protected organization?: Organization;
|
||||||
protected collection?: CollectionView;
|
protected collection?: CollectionView;
|
||||||
@@ -69,7 +76,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
private collectionService: CollectionAdminService,
|
private collectionService: CollectionAdminService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private platformUtilsService: PlatformUtilsService
|
private platformUtilsService: PlatformUtilsService
|
||||||
) {}
|
) {
|
||||||
|
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
|
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() {
|
async ngOnInit() {
|
||||||
this.filters = await this.buildAllFilters();
|
this.filters = await this.buildAllFilters();
|
||||||
if (!this.activeFilter.selectedCipherTypeNode) {
|
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;
|
this.isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable, OnDestroy } from "@angular/core";
|
||||||
import { combineLatestWith, ReplaySubject, switchMap, takeUntil } from "rxjs";
|
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 { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||||
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
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 { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
|
||||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
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 { Organization } from "@bitwarden/common/models/domain/organization";
|
||||||
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response";
|
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
|
||||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
|
||||||
|
|
||||||
import { VaultFilterService as BaseVaultFilterService } from "../../../vault/vault-filter/services/vault-filter.service";
|
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()
|
@Injectable()
|
||||||
export class VaultFilterService extends BaseVaultFilterService {
|
export class VaultFilterService extends BaseVaultFilterService implements OnDestroy {
|
||||||
protected collectionViews$ = new ReplaySubject<CollectionView[]>(1);
|
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(
|
constructor(
|
||||||
stateService: StateService,
|
stateService: StateService,
|
||||||
@@ -28,8 +37,8 @@ export class VaultFilterService extends BaseVaultFilterService {
|
|||||||
cipherService: CipherService,
|
cipherService: CipherService,
|
||||||
collectionService: CollectionService,
|
collectionService: CollectionService,
|
||||||
policyService: PolicyService,
|
policyService: PolicyService,
|
||||||
protected apiService: ApiService,
|
i18nService: I18nService,
|
||||||
i18nService: I18nService
|
protected collectionAdminService: CollectionAdminService
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
stateService,
|
stateService,
|
||||||
@@ -40,67 +49,42 @@ export class VaultFilterService extends BaseVaultFilterService {
|
|||||||
policyService,
|
policyService,
|
||||||
i18nService
|
i18nService
|
||||||
);
|
);
|
||||||
|
this.loadSubscriptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected 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
|
this._organizationFilter
|
||||||
.pipe(
|
.pipe(
|
||||||
|
filter((org) => org != null),
|
||||||
switchMap((org) => {
|
switchMap((org) => {
|
||||||
return this.loadCollections(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$)
|
takeUntil(this.destroy$)
|
||||||
)
|
)
|
||||||
.subscribe(this._filteredCollections);
|
.subscribe((collections) => {
|
||||||
|
this._collections.next(collections);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async loadCollections(org: Organization) {
|
async reloadCollections() {
|
||||||
if (org?.permissions && org?.canEditAnyCollection) {
|
this._collections.next(await this.loadCollections(this._organizationFilter.getValue()));
|
||||||
return await this.loadAdminCollections(org);
|
|
||||||
} else {
|
|
||||||
// TODO: remove when collections is refactored with observables
|
|
||||||
return await this.collectionService.getAllDecrypted();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAdminCollections(org: Organization): Promise<CollectionView[]> {
|
protected async loadCollections(org: Organization): Promise<CollectionAdminView[]> {
|
||||||
let collections: CollectionView[] = [];
|
let collections: CollectionAdminView[] = [];
|
||||||
if (org?.permissions && org?.canEditAnyCollection) {
|
if (canAccessVaultTab(org)) {
|
||||||
const collectionResponse = await this.apiService.getCollections(org.id);
|
collections = await this.collectionAdminService.getAll(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const noneCollection = new CollectionView();
|
const noneCollection = new CollectionAdminView();
|
||||||
noneCollection.name = this.i18nService.t("unassigned");
|
noneCollection.name = this.i18nService.t("unassigned");
|
||||||
noneCollection.organizationId = org.id;
|
noneCollection.organizationId = org.id;
|
||||||
collections.push(noneCollection);
|
collections.push(noneCollection);
|
||||||
}
|
}
|
||||||
return collections;
|
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 { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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 { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||||
import { TokenService } from "@bitwarden/common/abstractions/token.service";
|
import { TokenService } from "@bitwarden/common/abstractions/token.service";
|
||||||
import { TotpService } from "@bitwarden/common/abstractions/totp.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 { 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({
|
@Component({
|
||||||
selector: "app-org-vault-items",
|
selector: "app-org-vault-items",
|
||||||
templateUrl: "../../vault/vault-items.component.html",
|
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>();
|
@Output() onEventsClicked = new EventEmitter<CipherView>();
|
||||||
|
|
||||||
protected allCiphers: CipherView[] = [];
|
protected allCiphers: CipherView[] = [];
|
||||||
@@ -30,53 +57,81 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
|
|||||||
i18nService: I18nService,
|
i18nService: I18nService,
|
||||||
platformUtilsService: PlatformUtilsService,
|
platformUtilsService: PlatformUtilsService,
|
||||||
cipherService: CipherService,
|
cipherService: CipherService,
|
||||||
|
vaultFilterService: VaultFilterService,
|
||||||
eventCollectionService: EventCollectionService,
|
eventCollectionService: EventCollectionService,
|
||||||
totpService: TotpService,
|
totpService: TotpService,
|
||||||
passwordRepromptService: PasswordRepromptService,
|
passwordRepromptService: PasswordRepromptService,
|
||||||
|
dialogService: DialogService,
|
||||||
logService: LogService,
|
logService: LogService,
|
||||||
stateService: StateService,
|
stateService: StateService,
|
||||||
organizationService: OrganizationService,
|
organizationService: OrganizationService,
|
||||||
tokenService: TokenService,
|
tokenService: TokenService,
|
||||||
|
searchPipe: SearchPipe,
|
||||||
|
protected groupService: GroupService,
|
||||||
private apiService: ApiService
|
private apiService: ApiService
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
searchService,
|
searchService,
|
||||||
i18nService,
|
i18nService,
|
||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
|
vaultFilterService,
|
||||||
cipherService,
|
cipherService,
|
||||||
eventCollectionService,
|
eventCollectionService,
|
||||||
totpService,
|
totpService,
|
||||||
stateService,
|
stateService,
|
||||||
passwordRepromptService,
|
passwordRepromptService,
|
||||||
|
dialogService,
|
||||||
logService,
|
logService,
|
||||||
|
searchPipe,
|
||||||
organizationService,
|
organizationService,
|
||||||
tokenService
|
tokenService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
ngOnDestroy() {
|
||||||
this.deleted = deleted || false;
|
super.ngOnDestroy();
|
||||||
if (this.organization.canEditAnyCollection) {
|
}
|
||||||
this.accessEvents = this.organization.useEvents;
|
|
||||||
this.allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
|
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 {
|
} else {
|
||||||
this.allCiphers = (await this.cipherService.getAllDecrypted()).filter(
|
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);
|
await this.applyFilter(filter);
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
async refresh() {
|
||||||
if (this.organization.canViewAllCollections) {
|
await this.loadCiphers();
|
||||||
await super.applyFilter(filter);
|
await this.refreshCollections();
|
||||||
} else {
|
super.refresh();
|
||||||
const f = (c: CipherView) =>
|
|
||||||
c.organizationId === this.organization.id && (filter == null || filter(c));
|
|
||||||
await super.applyFilter(f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(timeout: number = null) {
|
async search(timeout: number = null) {
|
||||||
@@ -87,16 +142,136 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
|
|||||||
this.onEventsClicked.emit(c);
|
this.onEventsClicked.emit(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected deleteCipher(id: string) {
|
protected showFixOldAttachments(c: CipherView) {
|
||||||
if (!this.organization.canEditAnyCollection) {
|
return this.organization?.canEditAnyCollection && c.hasOldAttachments;
|
||||||
return super.deleteCipher(id, this.deleted);
|
}
|
||||||
|
|
||||||
|
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.deleteCipherAdmin(id)
|
||||||
: this.apiService.putDeleteCipherAdmin(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 { NgModule } from "@angular/core";
|
||||||
import { RouterModule, Routes } from "@angular/router";
|
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";
|
import { VaultComponent } from "./vault.component";
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: VaultComponent,
|
component: VaultComponent,
|
||||||
data: { titleId: "vaults" },
|
canActivate: [OrganizationPermissionsGuard],
|
||||||
|
data: { titleId: "vaults", organizationPermissions: canAccessVaultTab },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -55,10 +55,13 @@
|
|||||||
{{ trashCleanupWarning }}
|
{{ trashCleanupWarning }}
|
||||||
</app-callout>
|
</app-callout>
|
||||||
<app-org-vault-items
|
<app-org-vault-items
|
||||||
|
[activeFilter]="activeFilter"
|
||||||
|
[initOrganization]="organization"
|
||||||
|
(activeFilterChanged)="applyVaultFilter($event)"
|
||||||
(onCipherClicked)="editCipher($event)"
|
(onCipherClicked)="editCipher($event)"
|
||||||
(onAttachmentsClicked)="editCipherAttachments($event)"
|
(onAttachmentsClicked)="editCipherAttachments($event)"
|
||||||
(onAddCipher)="addCipher()"
|
(onAddCipher)="addCipher()"
|
||||||
(onCollectionsClicked)="editCipherCollections($event)"
|
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
|
||||||
(onEventsClicked)="viewEvents($event)"
|
(onEventsClicked)="viewEvents($event)"
|
||||||
(onCloneClicked)="cloneCipher($event)"
|
(onCloneClicked)="cloneCipher($event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { combineLatest, firstValueFrom, Subject } from "rxjs";
|
||||||
import { first } from "rxjs/operators";
|
import { first, switchMap, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
||||||
@@ -55,11 +55,12 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
organization: Organization;
|
organization: Organization;
|
||||||
trashCleanupWarning: string = null;
|
trashCleanupWarning: string = null;
|
||||||
activeFilter: VaultFilter = new VaultFilter();
|
activeFilter: VaultFilter = new VaultFilter();
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private vaultFilterService: VaultFilterService,
|
protected vaultFilterService: VaultFilterService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
@@ -73,82 +74,75 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
private passwordRepromptService: PasswordRepromptService
|
private passwordRepromptService: PasswordRepromptService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
async ngOnInit() {
|
||||||
this.trashCleanupWarning = this.i18nService.t(
|
this.trashCleanupWarning = this.i18nService.t(
|
||||||
this.platformUtilsService.isSelfHost()
|
this.platformUtilsService.isSelfHost()
|
||||||
? "trashCleanupWarningSelfHosted"
|
? "trashCleanupWarningSelfHosted"
|
||||||
: "trashCleanupWarning"
|
: "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.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(), takeUntil(this.destroy$)).subscribe((qParams) => {
|
||||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search;
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.vaultItemsComponent.reload(
|
// verifies that the organization has been set
|
||||||
this.activeFilter.buildFilter(),
|
combineLatest([this.route.queryParams, this.route.parent.params])
|
||||||
this.activeFilter.isDeleted
|
.pipe(
|
||||||
);
|
switchMap(async ([qParams, params]) => {
|
||||||
|
const cipherId = getCipherIdFromParams(qParams);
|
||||||
if (qParams.viewEvents != null) {
|
if (!cipherId) {
|
||||||
const cipher = this.vaultItemsComponent.ciphers.filter(
|
return;
|
||||||
(c) => c.id === qParams.viewEvents
|
|
||||||
);
|
|
||||||
if (cipher.length > 0) {
|
|
||||||
this.viewEvents(cipher[0]);
|
|
||||||
}
|
}
|
||||||
}
|
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 */
|
if (!this.organization.canUseAdminCollections) {
|
||||||
this.route.queryParams.subscribe(async (params) => {
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||||
const cipherId = getCipherIdFromParams(params);
|
this.ngZone.run(async () => {
|
||||||
if (cipherId) {
|
switch (message.command) {
|
||||||
if (
|
case "syncCompleted":
|
||||||
// Handle users with implicit collection access since they use the admin endpoint
|
if (message.successfully) {
|
||||||
this.organization.canUseAdminCollections ||
|
await Promise.all([
|
||||||
(await this.cipherService.get(cipherId)) != null
|
this.vaultFilterService.reloadCollections(),
|
||||||
) {
|
this.vaultItemsComponent.refresh(),
|
||||||
this.editCipherId(cipherId);
|
]);
|
||||||
} else {
|
this.changeDetectorRef.detectChanges();
|
||||||
this.platformUtilsService.showToast(
|
}
|
||||||
"error",
|
break;
|
||||||
this.i18nService.t("errorOccurred"),
|
|
||||||
this.i18nService.t("unknownCipher")
|
|
||||||
);
|
|
||||||
this.router.navigate([], {
|
|
||||||
queryParams: { cipherId: null, itemId: null },
|
|
||||||
queryParamsHandling: "merge",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
await this.syncService.fullSync(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyVaultFilter(filter: VaultFilter) {
|
async applyVaultFilter(filter: VaultFilter) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { SharedModule } from "../../shared/shared.module";
|
|||||||
import { OrganizationBadgeModule } from "../../vault/organization-badge/organization-badge.module";
|
import { OrganizationBadgeModule } from "../../vault/organization-badge/organization-badge.module";
|
||||||
import { PipesModule } from "../../vault/pipes/pipes.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 { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||||
import { VaultItemsComponent } from "./vault-items.component";
|
import { VaultItemsComponent } from "./vault-items.component";
|
||||||
import { VaultRoutingModule } from "./vault-routing.module";
|
import { VaultRoutingModule } from "./vault-routing.module";
|
||||||
@@ -16,6 +18,8 @@ import { VaultComponent } from "./vault.component";
|
|||||||
VaultFilterModule,
|
VaultFilterModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
LooseComponentsModule,
|
LooseComponentsModule,
|
||||||
|
GroupBadgeModule,
|
||||||
|
CollectionBadgeModule,
|
||||||
OrganizationBadgeModule,
|
OrganizationBadgeModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ import { AddEditCustomFieldsComponent } from "../vault/add-edit-custom-fields.co
|
|||||||
import { AddEditComponent } from "../vault/add-edit.component";
|
import { AddEditComponent } from "../vault/add-edit.component";
|
||||||
import { AttachmentsComponent } from "../vault/attachments.component";
|
import { AttachmentsComponent } from "../vault/attachments.component";
|
||||||
import { BulkActionsComponent } from "../vault/bulk-actions.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 { CollectionsComponent } from "../vault/collections.component";
|
||||||
import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
|
import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
|
||||||
import { ShareComponent } from "../vault/share.component";
|
import { ShareComponent } from "../vault/share.component";
|
||||||
@@ -135,10 +131,6 @@ import { SharedModule } from "./shared.module";
|
|||||||
AttachmentsComponent,
|
AttachmentsComponent,
|
||||||
BillingSyncKeyComponent,
|
BillingSyncKeyComponent,
|
||||||
BulkActionsComponent,
|
BulkActionsComponent,
|
||||||
BulkDeleteComponent,
|
|
||||||
BulkMoveComponent,
|
|
||||||
BulkRestoreComponent,
|
|
||||||
BulkShareComponent,
|
|
||||||
ChangeEmailComponent,
|
ChangeEmailComponent,
|
||||||
ChangeKdfComponent,
|
ChangeKdfComponent,
|
||||||
ChangePasswordComponent,
|
ChangePasswordComponent,
|
||||||
@@ -246,10 +238,6 @@ import { SharedModule } from "./shared.module";
|
|||||||
ApiKeyComponent,
|
ApiKeyComponent,
|
||||||
AttachmentsComponent,
|
AttachmentsComponent,
|
||||||
BulkActionsComponent,
|
BulkActionsComponent,
|
||||||
BulkDeleteComponent,
|
|
||||||
BulkMoveComponent,
|
|
||||||
BulkRestoreComponent,
|
|
||||||
BulkShareComponent,
|
|
||||||
ChangeEmailComponent,
|
ChangeEmailComponent,
|
||||||
ChangeKdfComponent,
|
ChangeKdfComponent,
|
||||||
ChangePasswordComponent,
|
ChangePasswordComponent,
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
BadgeListModule,
|
BadgeListModule,
|
||||||
BadgeModule,
|
BadgeModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
|
IconButtonModule,
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
DialogModule,
|
DialogModule,
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
|
||||||
IconModule,
|
IconModule,
|
||||||
LinkModule,
|
LinkModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
@@ -56,15 +56,16 @@ import "./locales";
|
|||||||
ButtonModule,
|
ButtonModule,
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
DialogModule,
|
DialogModule,
|
||||||
MultiSelectModule,
|
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
LinkModule,
|
LinkModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
|
MultiSelectModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
TabsModule,
|
TabsModule,
|
||||||
|
ToggleGroupModule,
|
||||||
|
|
||||||
// Web specific
|
// Web specific
|
||||||
],
|
],
|
||||||
@@ -86,16 +87,16 @@ import "./locales";
|
|||||||
ButtonModule,
|
ButtonModule,
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
DialogModule,
|
DialogModule,
|
||||||
MultiSelectModule,
|
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
|
LinkModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
|
MultiSelectModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
ToggleGroupModule,
|
|
||||||
LinkModule,
|
|
||||||
TabsModule,
|
TabsModule,
|
||||||
|
ToggleGroupModule,
|
||||||
|
|
||||||
// Web specific
|
// 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 { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.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 { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||||
import { Checkable, isChecked } from "@bitwarden/common/types/checkable";
|
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({
|
@Component({
|
||||||
selector: "app-vault-bulk-share",
|
selector: "vault-bulk-share-dialog",
|
||||||
templateUrl: "bulk-share.component.html",
|
templateUrl: "bulk-share-dialog.component.html",
|
||||||
})
|
})
|
||||||
export class BulkShareComponent implements OnInit {
|
export class BulkShareDialogComponent implements OnInit {
|
||||||
@Input() ciphers: CipherView[] = [];
|
ciphers: CipherView[] = [];
|
||||||
@Input() organizationId: string;
|
organizationId: string;
|
||||||
@Output() onShared = new EventEmitter();
|
|
||||||
|
|
||||||
nonShareableCount = 0;
|
nonShareableCount = 0;
|
||||||
collections: Checkable<CollectionView>[] = [];
|
collections: Checkable<CollectionView>[] = [];
|
||||||
organizations: Organization[] = [];
|
organizations: Organization[] = [];
|
||||||
shareableCiphers: CipherView[] = [];
|
shareableCiphers: CipherView[] = [];
|
||||||
formPromise: Promise<void>;
|
|
||||||
|
|
||||||
private writeableCollections: CollectionView[] = [];
|
private writeableCollections: CollectionView[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DIALOG_DATA) params: BulkShareDialogParams,
|
||||||
|
private dialogRef: DialogRef<BulkShareDialogResult>,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private collectionService: CollectionService,
|
private collectionService: CollectionService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private logService: LogService
|
private logService: LogService
|
||||||
) {}
|
) {
|
||||||
|
this.ciphers = params.ciphers ?? [];
|
||||||
|
this.organizationId = params.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.shareableCiphers = this.ciphers.filter(
|
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);
|
const checkedCollectionIds = this.collections.filter(isChecked).map((c) => c.id);
|
||||||
try {
|
try {
|
||||||
this.formPromise = this.cipherService.shareManyWithServer(
|
await this.cipherService.shareManyWithServer(
|
||||||
this.shareableCiphers,
|
this.shareableCiphers,
|
||||||
this.organizationId,
|
this.organizationId,
|
||||||
checkedCollectionIds
|
checkedCollectionIds
|
||||||
);
|
);
|
||||||
await this.formPromise;
|
|
||||||
this.onShared.emit();
|
|
||||||
const orgName =
|
const orgName =
|
||||||
this.organizations.find((o) => o.id === this.organizationId)?.name ??
|
this.organizations.find((o) => o.id === this.organizationId)?.name ??
|
||||||
this.i18nService.t("organization");
|
this.i18nService.t("organization");
|
||||||
@@ -84,10 +112,11 @@ export class BulkShareComponent implements OnInit {
|
|||||||
null,
|
null,
|
||||||
this.i18nService.t("movedItemsToOrg", orgName)
|
this.i18nService.t("movedItemsToOrg", orgName)
|
||||||
);
|
);
|
||||||
|
this.close(BulkShareDialogResult.Shared);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logService.error(e);
|
this.logService.error(e);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
check(c: Checkable<CollectionView>, select?: boolean) {
|
check(c: Checkable<CollectionView>, select?: boolean) {
|
||||||
c.checked = select == null ? !c.checked : select;
|
c.checked = select == null ? !c.checked : select;
|
||||||
@@ -112,4 +141,12 @@ export class BulkShareComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected cancel() {
|
||||||
|
this.close(BulkShareDialogResult.Canceled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private close(result: BulkShareDialogResult) {
|
||||||
|
this.dialogRef.close(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,3 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service";
|
import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||||
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
||||||
import { Organization } from "@bitwarden/common/models/domain/organization";
|
import { Organization } from "@bitwarden/common/models/domain/organization";
|
||||||
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
import { BulkDeleteComponent } from "./bulk-delete.component";
|
import {
|
||||||
import { BulkMoveComponent } from "./bulk-move.component";
|
BulkDeleteDialogResult,
|
||||||
import { BulkRestoreComponent } from "./bulk-restore.component";
|
openBulkDeleteDialog,
|
||||||
import { BulkShareComponent } from "./bulk-share.component";
|
} 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";
|
import { VaultItemsComponent } from "./vault-items.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -23,19 +36,10 @@ export class BulkActionsComponent {
|
|||||||
@Input() deleted: boolean;
|
@Input() deleted: boolean;
|
||||||
@Input() organization: Organization;
|
@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(
|
constructor(
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private modalService: ModalService,
|
private dialogService: DialogService,
|
||||||
private passwordRepromptService: PasswordRepromptService
|
private passwordRepromptService: PasswordRepromptService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,8 +48,8 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = this.vaultItemsComponent.getSelectedIds();
|
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
|
||||||
if (selectedIds.length === 0) {
|
if (selectedCipherIds.length === 0) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
@@ -54,20 +58,18 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [modal] = await this.modalService.openViewRef(
|
const dialog = openBulkDeleteDialog(this.dialogService, {
|
||||||
BulkDeleteComponent,
|
data: {
|
||||||
this.bulkDeleteModalRef,
|
permanent: this.deleted,
|
||||||
(comp) => {
|
cipherIds: selectedCipherIds,
|
||||||
comp.permanent = this.deleted;
|
organization: this.organization,
|
||||||
comp.cipherIds = selectedIds;
|
},
|
||||||
comp.organization = this.organization;
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
|
||||||
comp.onDeleted.subscribe(async () => {
|
const result = await lastValueFrom(dialog.closed);
|
||||||
modal.close();
|
if (result === BulkDeleteDialogResult.Deleted) {
|
||||||
await this.vaultItemsComponent.refresh();
|
await this.vaultItemsComponent.refresh();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkRestore() {
|
async bulkRestore() {
|
||||||
@@ -75,8 +77,8 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = this.vaultItemsComponent.getSelectedIds();
|
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
|
||||||
if (selectedIds.length === 0) {
|
if (selectedCipherIds.length === 0) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
@@ -85,18 +87,16 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [modal] = await this.modalService.openViewRef(
|
const dialog = openBulkRestoreDialog(this.dialogService, {
|
||||||
BulkRestoreComponent,
|
data: {
|
||||||
this.bulkRestoreModalRef,
|
cipherIds: selectedCipherIds,
|
||||||
(comp) => {
|
},
|
||||||
comp.cipherIds = selectedIds;
|
});
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
|
||||||
comp.onRestored.subscribe(async () => {
|
const result = await lastValueFrom(dialog.closed);
|
||||||
modal.close();
|
if (result === BulkRestoreDialogResult.Restored) {
|
||||||
await this.vaultItemsComponent.refresh();
|
this.vaultItemsComponent.refresh();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkShare() {
|
async bulkShare() {
|
||||||
@@ -104,7 +104,7 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCiphers = this.vaultItemsComponent.getSelected();
|
const selectedCiphers = this.vaultItemsComponent.selectedCiphers;
|
||||||
if (selectedCiphers.length === 0) {
|
if (selectedCiphers.length === 0) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
@@ -114,18 +114,12 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [modal] = await this.modalService.openViewRef(
|
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } });
|
||||||
BulkShareComponent,
|
|
||||||
this.bulkShareModalRef,
|
const result = await lastValueFrom(dialog.closed);
|
||||||
(comp) => {
|
if (result === BulkShareDialogResult.Shared) {
|
||||||
comp.ciphers = selectedCiphers;
|
this.vaultItemsComponent.refresh();
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
}
|
||||||
comp.onShared.subscribe(async () => {
|
|
||||||
modal.close();
|
|
||||||
await this.vaultItemsComponent.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkMove() {
|
async bulkMove() {
|
||||||
@@ -133,8 +127,8 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = this.vaultItemsComponent.getSelectedIds();
|
const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds;
|
||||||
if (selectedIds.length === 0) {
|
if (selectedCipherIds.length === 0) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
@@ -143,26 +137,22 @@ export class BulkActionsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [modal] = await this.modalService.openViewRef(
|
const dialog = openBulkMoveDialog(this.dialogService, {
|
||||||
BulkMoveComponent,
|
data: { cipherIds: selectedCipherIds },
|
||||||
this.bulkMoveModalRef,
|
});
|
||||||
(comp) => {
|
|
||||||
comp.cipherIds = selectedIds;
|
const result = await lastValueFrom(dialog.closed);
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
if (result === BulkMoveDialogResult.Moved) {
|
||||||
comp.onMoved.subscribe(async () => {
|
this.vaultItemsComponent.refresh();
|
||||||
modal.close();
|
}
|
||||||
await this.vaultItemsComponent.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectAll(select: boolean) {
|
selectAll(select: boolean) {
|
||||||
this.vaultItemsComponent.selectAll(select);
|
this.vaultItemsComponent.checkAll(select);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async promptPassword() {
|
private async promptPassword() {
|
||||||
const selectedCiphers = this.vaultItemsComponent.getSelected();
|
const selectedCiphers = this.vaultItemsComponent.selectedCiphers;
|
||||||
const notProtected = !selectedCiphers.find(
|
const notProtected = !selectedCiphers.find(
|
||||||
(cipher) => cipher.reprompt !== CipherRepromptType.None
|
(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 { 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";
|
import { GetOrgNameFromIdPipe } from "./get-organization-name.pipe";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [GetOrgNameFromIdPipe],
|
declarations: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe],
|
||||||
exports: [GetOrgNameFromIdPipe],
|
exports: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe],
|
||||||
})
|
})
|
||||||
export class PipesModule {}
|
export class PipesModule {}
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
this.filters = await this.buildAllFilters();
|
this.filters = await this.buildAllFilters();
|
||||||
await this.applyTypeFilter(
|
this.activeFilter.selectedCipherTypeNode =
|
||||||
(await firstValueFrom(this.filters?.typeFilter.data$)) as TreeNode<CipherTypeFilter>
|
(await this.getDefaultFilter()) as TreeNode<CipherTypeFilter>;
|
||||||
);
|
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export abstract class VaultFilterService {
|
|||||||
folderTree$: Observable<TreeNode<FolderFilter>>;
|
folderTree$: Observable<TreeNode<FolderFilter>>;
|
||||||
collectionTree$: Observable<TreeNode<CollectionFilter>>;
|
collectionTree$: Observable<TreeNode<CollectionFilter>>;
|
||||||
reloadCollections: () => Promise<void>;
|
reloadCollections: () => Promise<void>;
|
||||||
|
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
|
||||||
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
|
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
|
||||||
expandOrgFilter: () => Promise<void>;
|
expandOrgFilter: () => Promise<void>;
|
||||||
setOrganizationFilter: (organization: Organization) => void;
|
setOrganizationFilter: (organization: Organization) => void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, OnDestroy } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
Observable,
|
Observable,
|
||||||
of,
|
of,
|
||||||
ReplaySubject,
|
ReplaySubject,
|
||||||
Subject,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
takeUntil,
|
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
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 { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||||
import { FolderView } from "@bitwarden/common/models/view/folder.view";
|
import { FolderView } from "@bitwarden/common/models/view/folder.view";
|
||||||
|
|
||||||
|
import { CollectionAdminView } from "../../../organizations/core";
|
||||||
import {
|
import {
|
||||||
CipherTypeFilter,
|
CipherTypeFilter,
|
||||||
CollectionFilter,
|
CollectionFilter,
|
||||||
@@ -38,7 +37,7 @@ import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstracti
|
|||||||
const NestingDelimiter = "/";
|
const NestingDelimiter = "/";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VaultFilterService implements VaultFilterServiceAbstraction, OnDestroy {
|
export class VaultFilterService implements VaultFilterServiceAbstraction {
|
||||||
protected _collapsedFilterNodes = new BehaviorSubject<Set<string>>(null);
|
protected _collapsedFilterNodes = new BehaviorSubject<Set<string>>(null);
|
||||||
collapsedFilterNodes$: Observable<Set<string>> = this._collapsedFilterNodes.pipe(
|
collapsedFilterNodes$: Observable<Set<string>> = this._collapsedFilterNodes.pipe(
|
||||||
switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes()))
|
switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes()))
|
||||||
@@ -49,24 +48,31 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
|
|||||||
switchMap((orgs) => this.buildOrganizationTree(orgs))
|
switchMap((orgs) => this.buildOrganizationTree(orgs))
|
||||||
);
|
);
|
||||||
|
|
||||||
protected _filteredFolders = new ReplaySubject<FolderView[]>(1);
|
protected _organizationFilter = new BehaviorSubject<Organization>(null);
|
||||||
filteredFolders$: Observable<FolderView[]> = this._filteredFolders.asObservable();
|
|
||||||
protected _filteredCollections = new ReplaySubject<CollectionView[]>(1);
|
|
||||||
filteredCollections$: Observable<CollectionView[]> = this._filteredCollections.asObservable();
|
|
||||||
|
|
||||||
|
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(
|
folderTree$: Observable<TreeNode<FolderFilter>> = this.filteredFolders$.pipe(
|
||||||
map((folders) => this.buildFolderTree(folders))
|
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(
|
collectionTree$: Observable<TreeNode<CollectionFilter>> = this.filteredCollections$.pipe(
|
||||||
map((collections) => this.buildCollectionTree(collections))
|
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(
|
constructor(
|
||||||
protected stateService: StateService,
|
protected stateService: StateService,
|
||||||
protected organizationService: OrganizationService,
|
protected organizationService: OrganizationService,
|
||||||
@@ -75,43 +81,18 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
|
|||||||
protected collectionService: CollectionService,
|
protected collectionService: CollectionService,
|
||||||
protected policyService: PolicyService,
|
protected policyService: PolicyService,
|
||||||
protected i18nService: I18nService
|
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
|
// TODO: Remove once collections is refactored with observables
|
||||||
async reloadCollections() {
|
async reloadCollections() {
|
||||||
this.collectionViews$.next(await this.collectionService.getAllDecrypted());
|
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> {
|
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
|
||||||
await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes));
|
await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes));
|
||||||
this._collapsedFilterNodes.next(collapsedFilterNodes);
|
this._collapsedFilterNodes.next(collapsedFilterNodes);
|
||||||
@@ -208,6 +189,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest
|
|||||||
collectionCopy.id = c.id;
|
collectionCopy.id = c.id;
|
||||||
collectionCopy.organizationId = c.organizationId;
|
collectionCopy.organizationId = c.organizationId;
|
||||||
collectionCopy.icon = "bwi-collection";
|
collectionCopy.icon = "bwi-collection";
|
||||||
|
if (c instanceof CollectionAdminView) {
|
||||||
|
collectionCopy.groups = c.groups;
|
||||||
|
}
|
||||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
class="toggle-button"
|
class="toggle-button"
|
||||||
(click)="toggleCollapse(headerNode.node)"
|
(click)="toggleCollapse(headerNode.node)"
|
||||||
[attr.aria-expanded]="!isCollapsed(headerNode.node)"
|
[attr.aria-expanded]="!isCollapsed(headerNode.node)"
|
||||||
|
appA11yTitle="{{ 'toggleCollapse' | i18n }}: {{ headerNode.node.name | i18n }}"
|
||||||
aria-controls="sub-filters"
|
aria-controls="sub-filters"
|
||||||
title="{{ 'toggleCollapse' | i18n }}"
|
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-fw"
|
class="bwi bwi-fw"
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngIf="headerInfo.isSelectable"
|
*ngIf="headerInfo.isSelectable"
|
||||||
|
appA11yTitle="{{ isOrganizationFilter ? 'vault' : ('filter' | i18n) }}: {{
|
||||||
|
headerNode.node.name | i18n
|
||||||
|
}}"
|
||||||
class="filter-button"
|
class="filter-button"
|
||||||
(click)="onFilterSelect(headerNode)"
|
(click)="onFilterSelect(headerNode)"
|
||||||
>
|
>
|
||||||
@@ -55,8 +58,7 @@
|
|||||||
<span class="filter-buttons">
|
<span class="filter-buttons">
|
||||||
<button
|
<button
|
||||||
*ngIf="f.children.length"
|
*ngIf="f.children.length"
|
||||||
title="{{ 'toggleCollapse' | i18n }}"
|
appA11yTitle="{{ 'toggleCollapse' | i18n }}: {{ f.node.name }}"
|
||||||
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ f.node.name | i18n }}"
|
|
||||||
(click)="toggleCollapse(f.node)"
|
(click)="toggleCollapse(f.node)"
|
||||||
[attr.aria-expanded]="!isCollapsed(f.node)"
|
[attr.aria-expanded]="!isCollapsed(f.node)"
|
||||||
[attr.aria-controls]="f.node.name + '_children'"
|
[attr.aria-controls]="f.node.name + '_children'"
|
||||||
@@ -73,7 +75,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="filter-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 }"
|
[ngClass]="{ 'disabled-organization': isOrganizationFilter && !f.node.enabled }"
|
||||||
(click)="onFilterSelect(f)"
|
(click)="onFilterSelect(f)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { CipherType } from "@bitwarden/common/src/enums/cipherType";
|
import { CipherType } from "@bitwarden/common/src/enums/cipherType";
|
||||||
import { Organization } from "@bitwarden/common/src/models/domain/organization";
|
import { Organization } from "@bitwarden/common/src/models/domain/organization";
|
||||||
import { ITreeNodeObject } from "@bitwarden/common/src/models/domain/tree-node";
|
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 { FolderView } from "@bitwarden/common/src/models/view/folder.view";
|
||||||
|
|
||||||
|
import { CollectionAdminView } from "../../../../organizations/core";
|
||||||
|
|
||||||
export type CipherStatus = "all" | "favorites" | "trash" | CipherType;
|
export type CipherStatus = "all" | "favorites" | "trash" | CipherType;
|
||||||
|
|
||||||
export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string };
|
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 FolderFilter = FolderView & { icon: string };
|
||||||
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };
|
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };
|
||||||
|
|||||||
@@ -1,21 +1,162 @@
|
|||||||
<ng-container *ngIf="isPaging() ? pagedCiphers : ciphers as filteredCiphers">
|
<ng-container>
|
||||||
<table
|
<bit-table
|
||||||
class="table table-hover table-list table-ciphers"
|
*ngIf="filteredCiphers.length || filteredCollections.length"
|
||||||
*ngIf="filteredCiphers.length"
|
|
||||||
infiniteScroll
|
infiniteScroll
|
||||||
[infiniteScrollDistance]="1"
|
[infiniteScrollDistance]="1"
|
||||||
[infiniteScrollDisabled]="!isPaging()"
|
[infiniteScrollDisabled]="!isPaging()"
|
||||||
(scrolled)="loadMore()"
|
(scrolled)="loadMore()"
|
||||||
>
|
>
|
||||||
<tbody>
|
<ng-container header>
|
||||||
<tr *ngFor="let c of filteredCiphers">
|
<tr>
|
||||||
<td (click)="checkCipher(c)" class="table-list-checkbox">
|
<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 />
|
<input type="checkbox" [(ngModel)]="$any(c).checked" appStopProp />
|
||||||
</td>
|
</td>
|
||||||
<td (click)="checkCipher(c)" class="table-list-icon">
|
<td bitCell (click)="selectRow(c)">
|
||||||
<app-vault-icon [cipher]="c"></app-vault-icon>
|
<app-vault-icon [cipher]="c"></app-vault-icon>
|
||||||
</td>
|
</td>
|
||||||
<td (click)="checkCipher(c)" class="reduced-lh wrap">
|
<td bitCell (click)="selectRow(c)">
|
||||||
<a
|
<a
|
||||||
appStopProp
|
appStopProp
|
||||||
[routerLink]="[]"
|
[routerLink]="[]"
|
||||||
@@ -45,23 +186,31 @@
|
|||||||
<br />
|
<br />
|
||||||
<small appStopProp>{{ c.subTitle }}</small>
|
<small appStopProp>{{ c.subTitle }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td *ngIf="organizations.length > 0 && !organization" class="tw-w-28">
|
<td bitCell>
|
||||||
<app-org-badge
|
<ng-container *ngIf="!organization">
|
||||||
organizationName="{{ c.organizationId | orgNameFromId: organizations }}"
|
<app-org-badge
|
||||||
profileName="{{ profileName }}"
|
organizationName="{{ c.organizationId | orgNameFromId: organizations }}"
|
||||||
(onOrganizationClicked)="onOrganizationClicked(c.organizationId)"
|
profileName="{{ profileName }}"
|
||||||
>
|
(onOrganizationClicked)="onOrganizationClicked(c.organizationId)"
|
||||||
</app-org-badge>
|
>
|
||||||
|
</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>
|
||||||
<td class="table-list-options">
|
<td bitCell>
|
||||||
<button
|
<button
|
||||||
[bitMenuTriggerFor]="cipherOptions"
|
[bitMenuTriggerFor]="cipherOptions"
|
||||||
class="tw-border-none tw-bg-transparent tw-text-main"
|
size="small"
|
||||||
|
bitIconButton="bwi-ellipsis-v"
|
||||||
type="button"
|
type="button"
|
||||||
appA11yTitle="{{ 'options' | i18n }}"
|
appA11yTitle="{{ 'options' | i18n }}"
|
||||||
>
|
></button>
|
||||||
<i class="bwi bwi-ellipsis-v bwi-lg" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<bit-menu #cipherOptions>
|
<bit-menu #cipherOptions>
|
||||||
<ng-container *ngIf="c.type === cipherType.Login && !c.isDeleted">
|
<ng-container *ngIf="c.type === cipherType.Login && !c.isDeleted">
|
||||||
<button bitMenuItem (click)="copy(c, c.login.username, 'username', 'Username')">
|
<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>
|
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
|
||||||
{{ "moveToOrganization" | i18n }}
|
{{ "moveToOrganization" | i18n }}
|
||||||
</button>
|
</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>
|
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
|
||||||
{{ "collections" | i18n }}
|
{{ "collections" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
@@ -121,7 +274,7 @@
|
|||||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||||
{{ "restore" | i18n }}
|
{{ "restore" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button bitMenuItem (click)="delete(c)">
|
<button bitMenuItem (click)="deleteCipher(c)">
|
||||||
<span class="tw-text-danger">
|
<span class="tw-text-danger">
|
||||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||||
{{ (c.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
{{ (c.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
||||||
@@ -130,9 +283,9 @@
|
|||||||
</bit-menu>
|
</bit-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</ng-container>
|
||||||
</table>
|
</bit-table>
|
||||||
<div class="no-items" *ngIf="!filteredCiphers.length">
|
<div class="no-items" *ngIf="!filteredCiphers.length && !filteredCollections.length">
|
||||||
<ng-container *ngIf="!loaded">
|
<ng-container *ngIf="!loaded">
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-spinner bwi-spin text-muted"
|
class="bwi bwi-spinner bwi-spin text-muted"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
|
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 { 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 { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.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 { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
import { EventType } from "@bitwarden/common/enums/eventType";
|
import { EventType } from "@bitwarden/common/enums/eventType";
|
||||||
import { Organization } from "@bitwarden/common/models/domain/organization";
|
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 { 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;
|
const MaxCheckedCount = 500;
|
||||||
|
|
||||||
|
export type VaultItemRow = (CipherView | TreeNode<CollectionFilter>) & { checked?: boolean };
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-vault-items",
|
selector: "app-vault-items",
|
||||||
templateUrl: "vault-items.component.html",
|
templateUrl: "vault-items.component.html",
|
||||||
})
|
})
|
||||||
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
|
export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy {
|
||||||
@Input() showAddNew = true;
|
@Input() showAddNew = true;
|
||||||
|
@Input() activeFilter: VaultFilter;
|
||||||
|
@Output() activeFilterChanged = new EventEmitter<VaultFilter>();
|
||||||
@Output() onAttachmentsClicked = new EventEmitter<CipherView>();
|
@Output() onAttachmentsClicked = new EventEmitter<CipherView>();
|
||||||
@Output() onShareClicked = new EventEmitter<CipherView>();
|
@Output() onShareClicked = new EventEmitter<CipherView>();
|
||||||
@Output() onCollectionsClicked = new EventEmitter<CipherView>();
|
@Output() onEditCipherCollectionsClicked = new EventEmitter<CipherView>();
|
||||||
@Output() onCloneClicked = new EventEmitter<CipherView>();
|
@Output() onCloneClicked = new EventEmitter<CipherView>();
|
||||||
@Output() onOrganzationBadgeClicked = new EventEmitter<string>();
|
@Output() onOrganzationBadgeClicked = new EventEmitter<string>();
|
||||||
|
|
||||||
pagedCiphers: CipherView[] = [];
|
|
||||||
pageSize = 200;
|
|
||||||
cipherType = CipherType;
|
cipherType = CipherType;
|
||||||
actionPromise: Promise<any>;
|
actionPromise: Promise<any>;
|
||||||
userHasPremiumAccess = false;
|
userHasPremiumAccess = false;
|
||||||
organizations: Organization[] = [];
|
organizations: Organization[] = [];
|
||||||
profileName: string;
|
profileName: string;
|
||||||
noItemIcon = Icons.Search;
|
noItemIcon = Icons.Search;
|
||||||
|
groups: GroupView[] = [];
|
||||||
|
|
||||||
private didScroll = false;
|
protected pageSizeLimit = 200;
|
||||||
private pagedCiphersCount = 0;
|
protected isAllChecked = false;
|
||||||
private refreshing = 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(
|
constructor(
|
||||||
searchService: SearchService,
|
searchService: SearchService,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected platformUtilsService: PlatformUtilsService,
|
protected platformUtilsService: PlatformUtilsService,
|
||||||
|
protected vaultFilterService: VaultFilterService,
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected eventCollectionService: EventCollectionService,
|
protected eventCollectionService: EventCollectionService,
|
||||||
protected totpService: TotpService,
|
protected totpService: TotpService,
|
||||||
protected stateService: StateService,
|
protected stateService: StateService,
|
||||||
protected passwordRepromptService: PasswordRepromptService,
|
protected passwordRepromptService: PasswordRepromptService,
|
||||||
private logService: LogService,
|
protected dialogService: DialogService,
|
||||||
|
protected logService: LogService,
|
||||||
|
private searchPipe: SearchPipe,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private tokenService: TokenService
|
private tokenService: TokenService
|
||||||
) {
|
) {
|
||||||
@@ -63,36 +122,30 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
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.
|
// load() is called after the page loads and the first sync has completed.
|
||||||
// Do not use ngOnInit() for anything that requires sync data.
|
// Do not use ngOnInit() for anything that requires sync data.
|
||||||
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||||
await super.load(filter, deleted);
|
await super.load(filter, deleted);
|
||||||
|
this.updateSearchedCollections(this.collections);
|
||||||
this.profileName = await this.tokenService.getName();
|
this.profileName = await this.tokenService.getName();
|
||||||
this.organizations = await this.organizationService.getAll();
|
this.organizations = await this.organizationService.getAll();
|
||||||
this.userHasPremiumAccess = await this.stateService.getCanAccessPremium();
|
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() {
|
async refresh() {
|
||||||
try {
|
try {
|
||||||
this.refreshing = true;
|
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() {
|
isPaging() {
|
||||||
const searching = this.isSearching();
|
const searching = this.isSearching();
|
||||||
if (searching && this.didScroll) {
|
if (searching && this.didScroll) {
|
||||||
this.resetPaging();
|
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() {
|
async resetPaging() {
|
||||||
|
this.pagedCollections = [];
|
||||||
this.pagedCiphers = [];
|
this.pagedCiphers = [];
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
}
|
}
|
||||||
@@ -121,6 +217,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
[this.filter, this.deletedFilter],
|
[this.filter, this.deletedFilter],
|
||||||
indexedCiphers
|
indexedCiphers
|
||||||
);
|
);
|
||||||
|
this.updateSearchedCollections(this.collections);
|
||||||
this.resetPaging();
|
this.resetPaging();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +239,8 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
this.onShareClicked.emit(c);
|
this.onShareClicked.emit(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
collections(c: CipherView) {
|
editCipherCollections(c: CipherView) {
|
||||||
this.onCollectionsClicked.emit(c);
|
this.onEditCipherCollectionsClicked.emit(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clone(c: CipherView) {
|
async clone(c: CipherView) {
|
||||||
@@ -153,7 +250,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
this.onCloneClicked.emit(c);
|
this.onCloneClicked.emit(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(c: CipherView): Promise<boolean> {
|
async deleteCipher(c: CipherView): Promise<boolean> {
|
||||||
if (!(await this.repromptCipher(c))) {
|
if (!(await this.repromptCipher(c))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +272,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.actionPromise = this.deleteCipher(c.id, permanent);
|
this.actionPromise = this.deleteCipherWithServer(c.id, permanent);
|
||||||
await this.actionPromise;
|
await this.actionPromise;
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"success",
|
"success",
|
||||||
@@ -189,6 +286,33 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
this.actionPromise = null;
|
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> {
|
async restore(c: CipherView): Promise<boolean> {
|
||||||
if (this.actionPromise != null || !c.isDeleted) {
|
if (this.actionPromise != null || !c.isDeleted) {
|
||||||
return;
|
return;
|
||||||
@@ -215,6 +339,85 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
this.actionPromise = null;
|
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) {
|
async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) {
|
||||||
if (
|
if (
|
||||||
this.passwordRepromptService.protectedFields().includes(aType) &&
|
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) {
|
if (select) {
|
||||||
this.selectAll(false);
|
this.checkAll(false);
|
||||||
}
|
}
|
||||||
const selectCount =
|
const items: VaultItemRow[] = this.ciphers;
|
||||||
select && this.ciphers.length > MaxCheckedCount ? MaxCheckedCount : this.ciphers.length;
|
if (!items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length;
|
||||||
for (let i = 0; i < selectCount; i++) {
|
for (let i = 0; i < selectCount; i++) {
|
||||||
this.checkCipher(this.ciphers[i], select);
|
this.checkRow(items[i], select);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCipher(c: CipherView, select?: boolean) {
|
checkRow(item: VaultItemRow, select?: boolean) {
|
||||||
(c as any).checked = select == null ? !(c as any).checked : select;
|
// Collections can't be managed in end user vault
|
||||||
|
if (!(item instanceof CipherView)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item.checked = select ?? !item.checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelected(): CipherView[] {
|
get selectedCiphers(): CipherView[] {
|
||||||
if (this.ciphers == null) {
|
if (!this.ciphers) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.ciphers.filter((c) => !!(c as any).checked);
|
return this.ciphers.filter((c) => !!(c as VaultItemRow).checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedIds(): string[] {
|
get selectedCipherIds(): string[] {
|
||||||
return this.getSelected().map((c) => c.id);
|
return this.selectedCiphers.map((c) => c.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
displayTotpCopyButton(cipher: CipherView) {
|
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
|
// 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
|
return permanent
|
||||||
? this.cipherService.deleteWithServer(id)
|
? this.cipherService.deleteWithServer(id)
|
||||||
: this.cipherService.softDeleteWithServer(id);
|
: this.cipherService.softDeleteWithServer(id);
|
||||||
@@ -306,10 +550,19 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDe
|
|||||||
return c.hasOldAttachments && c.organizationId == null;
|
return c.hasOldAttachments && c.organizationId == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async repromptCipher(c: CipherView) {
|
protected async repromptCipher(c?: CipherView) {
|
||||||
return (
|
if (c) {
|
||||||
c.reprompt === CipherRepromptType.None ||
|
return (
|
||||||
(await this.passwordRepromptService.showPasswordPrompt())
|
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 }}
|
{{ trashCleanupWarning }}
|
||||||
</app-callout>
|
</app-callout>
|
||||||
<app-vault-items
|
<app-vault-items
|
||||||
|
[activeFilter]="activeFilter"
|
||||||
|
(activeFilterChanged)="applyVaultFilter($event)"
|
||||||
(onCipherClicked)="editCipher($event)"
|
(onCipherClicked)="editCipher($event)"
|
||||||
(onAttachmentsClicked)="editCipherAttachments($event)"
|
(onAttachmentsClicked)="editCipherAttachments($event)"
|
||||||
(onAddCipher)="addCipher()"
|
(onAddCipher)="addCipher()"
|
||||||
(onShareClicked)="shareCipher($event)"
|
(onShareClicked)="shareCipher($event)"
|
||||||
(onCollectionsClicked)="editCipherCollections($event)"
|
(onEditCipherCollectionsClicked)="editCipherCollections($event)"
|
||||||
(onCloneClicked)="cloneCipher($event)"
|
(onCloneClicked)="cloneCipher($event)"
|
||||||
(onOrganzationBadgeClicked)="applyOrganizationFilter($event)"
|
(onOrganzationBadgeClicked)="applyOrganizationFilter($event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, Subject } from "rxjs";
|
||||||
import { first } from "rxjs/operators";
|
import { first, switchMap, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
|
||||||
@@ -67,6 +67,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
showPremiumCallout = false;
|
showPremiumCallout = false;
|
||||||
trashCleanupWarning: string = null;
|
trashCleanupWarning: string = null;
|
||||||
activeFilter: VaultFilter = new VaultFilter();
|
activeFilter: VaultFilter = new VaultFilter();
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
@@ -97,63 +98,72 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
: "trashCleanupWarning"
|
: "trashCleanupWarning"
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
this.route.queryParams
|
||||||
this.route.queryParams.pipe(first()).subscribe(async (params) => {
|
.pipe(
|
||||||
await this.syncService.fullSync(false);
|
first(),
|
||||||
const canAccessPremium = await this.stateService.getCanAccessPremium();
|
switchMap(async (params: Params) => {
|
||||||
this.showPremiumCallout =
|
await this.syncService.fullSync(false);
|
||||||
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
|
await this.vaultFilterService.reloadCollections();
|
||||||
|
await this.vaultItemsComponent.reload();
|
||||||
|
|
||||||
await this.vaultFilterService.reloadCollections();
|
const canAccessPremium = await this.stateService.getCanAccessPremium();
|
||||||
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
|
this.showPremiumCallout =
|
||||||
|
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
|
||||||
|
this.showUpdateKey = !(await this.cryptoService.hasEncKey());
|
||||||
|
|
||||||
const cipherId = getCipherIdFromParams(params);
|
const cipherId = getCipherIdFromParams(params);
|
||||||
|
if (!cipherId) {
|
||||||
if (cipherId) {
|
return;
|
||||||
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 cipherView = new CipherView();
|
||||||
});
|
cipherView.id = cipherId;
|
||||||
|
if (params.action === "clone") {
|
||||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
await this.cloneCipher(cipherView);
|
||||||
this.ngZone.run(async () => {
|
} else if (params.action === "edit") {
|
||||||
switch (message.command) {
|
await this.editCipher(cipherView);
|
||||||
case "syncCompleted":
|
|
||||||
if (message.successfully) {
|
|
||||||
await Promise.all([
|
|
||||||
this.vaultFilterService.reloadCollections(),
|
|
||||||
this.vaultItemsComponent.load(this.vaultItemsComponent.filter),
|
|
||||||
]);
|
|
||||||
this.changeDetectorRef.detectChanges();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
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() {
|
ngOnDestroy() {
|
||||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyVaultFilter(filter: VaultFilter) {
|
async applyVaultFilter(filter: VaultFilter) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { NgModule } from "@angular/core";
|
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 { SharedModule, LooseComponentsModule } from "../shared";
|
||||||
|
|
||||||
|
import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module";
|
||||||
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
|
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
|
||||||
import { PipesModule } from "./pipes/pipes.module";
|
import { PipesModule } from "./pipes/pipes.module";
|
||||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||||
@@ -14,9 +17,12 @@ import { VaultComponent } from "./vault.component";
|
|||||||
VaultFilterModule,
|
VaultFilterModule,
|
||||||
VaultRoutingModule,
|
VaultRoutingModule,
|
||||||
OrganizationBadgeModule,
|
OrganizationBadgeModule,
|
||||||
|
GroupBadgeModule,
|
||||||
|
CollectionBadgeModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
LooseComponentsModule,
|
LooseComponentsModule,
|
||||||
|
BulkDialogsModule,
|
||||||
],
|
],
|
||||||
declarations: [VaultComponent, VaultItemsComponent],
|
declarations: [VaultComponent, VaultItemsComponent],
|
||||||
exports: [VaultComponent],
|
exports: [VaultComponent],
|
||||||
|
|||||||
@@ -460,6 +460,9 @@
|
|||||||
"vaultItems": {
|
"vaultItems": {
|
||||||
"message": "Vault items"
|
"message": "Vault items"
|
||||||
},
|
},
|
||||||
|
"filter": {
|
||||||
|
"message": "Filter"
|
||||||
|
},
|
||||||
"moveSelectedToOrg": {
|
"moveSelectedToOrg": {
|
||||||
"message": "Move selected to organization"
|
"message": "Move selected to organization"
|
||||||
},
|
},
|
||||||
@@ -566,6 +569,12 @@
|
|||||||
"deletedFolder": {
|
"deletedFolder": {
|
||||||
"message": "Folder deleted"
|
"message": "Folder deleted"
|
||||||
},
|
},
|
||||||
|
"editInfo": {
|
||||||
|
"message": "Edit info"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"message": "Access"
|
||||||
|
},
|
||||||
"loggedOut": {
|
"loggedOut": {
|
||||||
"message": "Logged out"
|
"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."
|
"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": {
|
"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": {
|
"placeholders": {
|
||||||
"count": {
|
"count": {
|
||||||
"content": "$1",
|
"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": {
|
"moveSelectedItemsDesc": {
|
||||||
"message": "Choose a folder that you would like to move the $COUNT$ selected item(s) to.",
|
"message": "Choose a folder that you would like to move the $COUNT$ selected item(s) to.",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2707,6 +2728,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deletedCollections": {
|
||||||
|
"message": "Deleted collections"
|
||||||
|
},
|
||||||
"deletedCollectionId": {
|
"deletedCollectionId": {
|
||||||
"message": "Deleted collection $ID$.",
|
"message": "Deleted collection $ID$.",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3689,7 +3713,7 @@
|
|||||||
"message": "Restore items"
|
"message": "Restore items"
|
||||||
},
|
},
|
||||||
"restoreSelectedItemsDesc": {
|
"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": {
|
"placeholders": {
|
||||||
"count": {
|
"count": {
|
||||||
"content": "$1",
|
"content": "$1",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let u of searchedUsers">
|
<tr *ngFor="let u of searchedUsers">
|
||||||
<td (click)="checkUser(u)" class="table-list-checkbox">
|
<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>
|
||||||
<td width="30">
|
<td width="30">
|
||||||
<bit-avatar [text]="u | userName" [id]="u.userId" size="small"></bit-avatar>
|
<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>
|
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ng-container *ngIf="u.twoFactorEnabled">
|
<ng-container *ngIf="$any(u).twoFactorEnabled">
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-lock"
|
class="bwi bwi-lock"
|
||||||
title="{{ 'userUsingTwoStep' | i18n }}"
|
title="{{ 'userUsingTwoStep' | i18n }}"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export class VaultItemsComponent {
|
|||||||
|
|
||||||
loaded = false;
|
loaded = false;
|
||||||
ciphers: CipherView[] = [];
|
ciphers: CipherView[] = [];
|
||||||
searchText: string;
|
|
||||||
searchPlaceholder: string = null;
|
searchPlaceholder: string = null;
|
||||||
filter: (cipher: CipherView) => boolean = null;
|
filter: (cipher: CipherView) => boolean = null;
|
||||||
deleted = false;
|
deleted = false;
|
||||||
@@ -24,11 +23,18 @@ export class VaultItemsComponent {
|
|||||||
protected searchPending = false;
|
protected searchPending = false;
|
||||||
|
|
||||||
private searchTimeout: any = null;
|
private searchTimeout: any = null;
|
||||||
|
private _searchText: string = null;
|
||||||
|
get searchText() {
|
||||||
|
return this._searchText;
|
||||||
|
}
|
||||||
|
set searchText(value: string) {
|
||||||
|
this._searchText = value;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(protected searchService: SearchService) {}
|
constructor(protected searchService: SearchService) {}
|
||||||
|
|
||||||
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||||
this.deleted = deleted || false;
|
this.deleted = deleted ?? false;
|
||||||
await this.applyFilter(filter);
|
await this.applyFilter(filter);
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import { Pipe, PipeTransform } from "@angular/core";
|
import { Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
|
type PropertyValueFunction<T> = (item: T) => { toString: () => string };
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: "search",
|
name: "search",
|
||||||
})
|
})
|
||||||
export class SearchPipe implements PipeTransform {
|
export class SearchPipe implements PipeTransform {
|
||||||
transform(
|
transform<T>(
|
||||||
items: any[],
|
items: T[],
|
||||||
searchText: string,
|
searchText: string,
|
||||||
prop1?: string,
|
prop1?: keyof T,
|
||||||
prop2?: string,
|
prop2?: keyof T,
|
||||||
prop3?: string
|
prop3?: keyof T
|
||||||
): any[] {
|
): 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) {
|
if (items == null || items.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -21,27 +37,30 @@ export class SearchPipe implements PipeTransform {
|
|||||||
|
|
||||||
searchText = searchText.trim().toLowerCase();
|
searchText = searchText.trim().toLowerCase();
|
||||||
return items.filter((i) => {
|
return items.filter((i) => {
|
||||||
if (
|
if (prop1 != null) {
|
||||||
prop1 != null &&
|
const propValue = typeof prop1 === "function" ? prop1(i) : i[prop1];
|
||||||
i[prop1] != null &&
|
|
||||||
i[prop1].toString().toLowerCase().indexOf(searchText) > -1
|
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
|
||||||
) {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
prop2 != null &&
|
if (prop2 != null) {
|
||||||
i[prop2] != null &&
|
const propValue = typeof prop2 === "function" ? prop2(i) : i[prop2];
|
||||||
i[prop2].toString().toLowerCase().indexOf(searchText) > -1
|
|
||||||
) {
|
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
prop3 != null &&
|
if (prop3 != null) {
|
||||||
i[prop3] != null &&
|
const propValue = typeof prop3 === "function" ? prop3(i) : i[prop3];
|
||||||
i[prop3].toString().toLowerCase().indexOf(searchText) > -1
|
|
||||||
) {
|
if (propValue?.toString().toLowerCase().indexOf(searchText) > -1) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
|||||||
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
||||||
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
||||||
import { CipherRequest } from "../models/request/cipher.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 { CollectionRequest } from "../models/request/collection.request";
|
||||||
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
||||||
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
|
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
|
||||||
@@ -316,13 +317,16 @@ export abstract class ApiService {
|
|||||||
) => Promise<AttachmentUploadDataResponse>;
|
) => Promise<AttachmentUploadDataResponse>;
|
||||||
postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise<any>;
|
postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise<any>;
|
||||||
|
|
||||||
getCollectionDetails: (
|
|
||||||
organizationId: string,
|
|
||||||
id: string
|
|
||||||
) => Promise<CollectionAccessDetailsResponse>;
|
|
||||||
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
|
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
|
||||||
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
|
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
|
||||||
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;
|
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;
|
||||||
|
getCollectionAccessDetails: (
|
||||||
|
organizationId: string,
|
||||||
|
id: string
|
||||||
|
) => Promise<CollectionAccessDetailsResponse>;
|
||||||
|
getManyCollectionsWithAccessDetails: (
|
||||||
|
orgId: string
|
||||||
|
) => Promise<ListResponse<CollectionAccessDetailsResponse>>;
|
||||||
postCollection: (
|
postCollection: (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
request: CollectionRequest
|
request: CollectionRequest
|
||||||
@@ -338,6 +342,7 @@ export abstract class ApiService {
|
|||||||
request: CollectionRequest
|
request: CollectionRequest
|
||||||
) => Promise<CollectionResponse>;
|
) => Promise<CollectionResponse>;
|
||||||
deleteCollection: (organizationId: string, id: string) => Promise<any>;
|
deleteCollection: (organizationId: string, id: string) => Promise<any>;
|
||||||
|
deleteManyCollections: (request: CollectionBulkDeleteRequest) => Promise<any>;
|
||||||
deleteCollectionUser: (
|
deleteCollectionUser: (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Organization } from "../../models/domain/organization";
|
|||||||
import { I18nService } from "../i18n.service";
|
import { I18nService } from "../i18n.service";
|
||||||
|
|
||||||
export function canAccessVaultTab(org: Organization): boolean {
|
export function canAccessVaultTab(org: Organization): boolean {
|
||||||
return org.isManager;
|
return org.canViewAssignedCollections || org.canViewAllCollections || org.canManageGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessSettingsTab(org: Organization): boolean {
|
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 {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} obj - The node to be added to the tree
|
||||||
* @param {ITreeNodeObject} parent - The parent node of the `obj` node
|
* @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(
|
static nestedTraverse(
|
||||||
nodeTree: TreeNode<ITreeNodeObject>[],
|
nodeTree: TreeNode<ITreeNodeObject>[],
|
||||||
@@ -22,18 +22,19 @@ export class ServiceUtils {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = partIndex === parts.length - 1;
|
const end: boolean = partIndex === parts.length - 1;
|
||||||
const partName = parts[partIndex];
|
const partName: string = parts[partIndex];
|
||||||
|
|
||||||
for (let i = 0; i < nodeTree.length; i++) {
|
for (let i = 0; i < nodeTree.length; i++) {
|
||||||
if (nodeTree[i].node.name !== parts[partIndex]) {
|
if (nodeTree[i].node.name !== partName) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (end && nodeTree[i].node.id !== obj.id) {
|
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));
|
nodeTree.push(new TreeNode(obj, parent, partName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Move down the tree to the next level
|
||||||
ServiceUtils.nestedTraverse(
|
ServiceUtils.nestedTraverse(
|
||||||
nodeTree[i].children,
|
nodeTree[i].children,
|
||||||
partIndex + 1,
|
partIndex + 1,
|
||||||
@@ -45,12 +46,17 @@ export class ServiceUtils {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there's no node here with the same name...
|
||||||
if (nodeTree.filter((n) => n.node.name === partName).length === 0) {
|
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) {
|
if (end) {
|
||||||
nodeTree.push(new TreeNode(obj, parent, partName));
|
nodeTree.push(new TreeNode(obj, parent, partName));
|
||||||
return;
|
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(
|
ServiceUtils.nestedTraverse(
|
||||||
nodeTree,
|
nodeTree,
|
||||||
0,
|
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 { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
||||||
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
||||||
import { CipherRequest } from "../models/request/cipher.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 { CollectionRequest } from "../models/request/collection.request";
|
||||||
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
||||||
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
|
import { DeviceVerificationRequest } from "../models/request/device-verification.request";
|
||||||
@@ -812,7 +813,7 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
|
|
||||||
// Collections APIs
|
// Collections APIs
|
||||||
|
|
||||||
async getCollectionDetails(
|
async getCollectionAccessDetails(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string
|
id: string
|
||||||
): Promise<CollectionAccessDetailsResponse> {
|
): Promise<CollectionAccessDetailsResponse> {
|
||||||
@@ -842,6 +843,19 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
return new ListResponse(r, CollectionResponse);
|
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(
|
async getCollectionUsers(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: 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(
|
deleteCollectionUser(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const commonStyles = [
|
|||||||
"before:-tw-inset-x-[0.1em]",
|
"before:-tw-inset-x-[0.1em]",
|
||||||
"before:tw-rounded-md",
|
"before:tw-rounded-md",
|
||||||
"before:tw-transition",
|
"before:tw-transition",
|
||||||
"before:tw-ring-2",
|
"focus-visible:before:tw-ring-2",
|
||||||
"focus-visible:before:tw-ring-text-contrast",
|
"focus-visible:before:tw-ring-text-contrast",
|
||||||
"focus-visible:tw-z-10",
|
"focus-visible:tw-z-10",
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user