[AC-1782] Flexible collections import behavior in Password Manager and Admin Console (#6888)

* Added logic to only return organisations where user has import permissions and collections that he manages on Import

* Changed the UnassignedCollections validation logic

* Added validation to check if the user is coming from AdminConsole on the import component

* Added import collection service abstraction to allow get admin collections

* Corrected feature flag reads on import component

* Refactor import component methods ngOnInit and performImport to improve codescene
Using FeatureFlag Observable

* Modified validation to allow import if user has organizations to import into

* Using the new organization flexiblecollections property on import

* Created collection-admin-import.service to return all the org collections to the import on Admin Console

* Small changes on import flexible collections

* Fix linting issues

* changed canAccessImport rules and deprecated canAccessImportExport

* Validating if user canAccessImportExport instead of admin before calling the handleOrganizationImportInit.

* AC-2095 - Corrected getAllAdminCollections from ImportCollectionAdminService to properly get all the collections on AdminConsole

* Reverting AC-2095

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
aj-rosado 2024-01-29 15:11:19 +00:00 committed by GitHub
parent 289a5cd002
commit 305fd39871
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 184 additions and 96 deletions

View File

@ -49,8 +49,8 @@ const routes: Routes = [
{ {
path: "import", path: "import",
loadComponent: () => loadComponent: () =>
import("../../../tools/import/import-web.component").then( import("../../../tools/import/admin-import.component").then(
(mod) => mod.ImportWebComponent, (mod) => mod.AdminImportComponent,
), ),
canActivate: [OrganizationPermissionsGuard], canActivate: [OrganizationPermissionsGuard],
data: { data: {

View File

@ -0,0 +1,24 @@
import { Component } from "@angular/core";
import { ImportCollectionServiceAbstraction } from "@bitwarden/importer/core";
import { ImportComponent } from "@bitwarden/importer/ui";
import { SharedModule } from "../../shared";
import { CollectionAdminService } from "../../vault/core/collection-admin.service";
import { ImportCollectionAdminService } from "./import-collection-admin.service";
import { ImportWebComponent } from "./import-web.component";
@Component({
templateUrl: "import-web.component.html",
standalone: true,
imports: [SharedModule, ImportComponent],
providers: [
{
provide: ImportCollectionServiceAbstraction,
useClass: ImportCollectionAdminService,
deps: [CollectionAdminService],
},
],
})
export class AdminImportComponent extends ImportWebComponent {}

View File

@ -0,0 +1,14 @@
import { Injectable } from "@angular/core";
import { ImportCollectionServiceAbstraction } from "../../../../../../libs/importer/src/services/import-collection.service.abstraction";
import { CollectionAdminService } from "../../vault/core/collection-admin.service";
import { CollectionAdminView } from "../../vault/core/views/collection-admin.view";
@Injectable()
export class ImportCollectionAdminService implements ImportCollectionServiceAbstraction {
constructor(private collectionAdminService: CollectionAdminService) {}
async getAllAdminCollections(organizationId: string): Promise<CollectionAdminView[]> {
return await this.collectionAdminService.getAll(organizationId);
}
}

View File

@ -57,6 +57,10 @@ export function canAccessAdmin(i18nService: I18nService) {
); );
} }
/**
* @deprecated
* To be removed after Flexible Collections.
**/
export function canAccessImportExport(i18nService: I18nService) { export function canAccessImportExport(i18nService: I18nService) {
return map<Organization[], Organization[]>((orgs) => return map<Organization[], Organization[]>((orgs) =>
orgs orgs
@ -65,6 +69,17 @@ export function canAccessImportExport(i18nService: I18nService) {
); );
} }
export function canAccessImport(i18nService: I18nService) {
return map<Organization[], Organization[]>((orgs) =>
orgs
.filter(
(org) =>
org.canAccessImportExport || (org.canCreateNewCollections && org.flexibleCollections),
)
.sort(Utils.getSortFunction(i18nService, "name")),
);
}
/** /**
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser) * Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
* @deprecated Use organizationService.memberOrganizations$ instead * @deprecated Use organizationService.memberOrganizations$ instead

View File

@ -2,9 +2,11 @@ import { CommonModule } from "@angular/common";
import { import {
Component, Component,
EventEmitter, EventEmitter,
Inject,
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
Optional,
Output, Output,
ViewChild, ViewChild,
} from "@angular/core"; } from "@angular/core";
@ -16,7 +18,7 @@ import { filter, map, takeUntil } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { import {
canAccessImportExport, canAccessImport,
OrganizationService, OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@ -50,6 +52,7 @@ import { ImportOption, ImportResult, ImportType } from "../models";
import { import {
ImportApiService, ImportApiService,
ImportApiServiceAbstraction, ImportApiServiceAbstraction,
ImportCollectionServiceAbstraction,
ImportService, ImportService,
ImportServiceAbstraction, ImportServiceAbstraction,
} from "../services"; } from "../services";
@ -129,6 +132,7 @@ export class ImportComponent implements OnInit, OnDestroy {
protected destroy$ = new Subject<void>(); protected destroy$ = new Subject<void>();
private _importBlockedByPolicy = false; private _importBlockedByPolicy = false;
private _isFromAC = false;
formGroup = this.formBuilder.group({ formGroup = this.formBuilder.group({
vaultSelector: [ vaultSelector: [
@ -176,9 +180,12 @@ export class ImportComponent implements OnInit, OnDestroy {
protected syncService: SyncService, protected syncService: SyncService,
protected dialogService: DialogService, protected dialogService: DialogService,
protected folderService: FolderService, protected folderService: FolderService,
protected collectionService: CollectionService,
protected organizationService: OrganizationService, protected organizationService: OrganizationService,
protected collectionService: CollectionService,
protected formBuilder: FormBuilder, protected formBuilder: FormBuilder,
@Inject(ImportCollectionServiceAbstraction)
@Optional()
protected importCollectionService: ImportCollectionServiceAbstraction,
) {} ) {}
protected get importBlockedByPolicy(): boolean { protected get importBlockedByPolicy(): boolean {
@ -200,41 +207,12 @@ export class ImportComponent implements OnInit, OnDestroy {
this.setImportOptions(); this.setImportOptions();
await this.initializeOrganizations(); await this.initializeOrganizations();
if (this.organizationId && this.canAccessImportExport(this.organizationId)) {
if (this.organizationId) { this.handleOrganizationImportInit();
this.formGroup.controls.vaultSelector.patchValue(this.organizationId);
this.formGroup.controls.vaultSelector.disable();
this.collections$ = Utils.asyncToObservable(() =>
this.collectionService
.getAllDecrypted()
.then((c) => c.filter((c2) => c2.organizationId === this.organizationId)),
);
} else { } else {
// Filter out the `no folder`-item from folderViews$ this.handleImportInit();
this.folders$ = this.folderService.folderViews$.pipe(
map((folders) => folders.filter((f) => f.id != null)),
);
this.formGroup.controls.targetSelector.disable();
this.formGroup.controls.vaultSelector.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {
this.organizationId = value != "myVault" ? value : undefined;
if (!this._importBlockedByPolicy) {
this.formGroup.controls.targetSelector.enable();
}
if (value) {
this.collections$ = Utils.asyncToObservable(() =>
this.collectionService
.getAllDecrypted()
.then((c) => c.filter((c2) => c2.organizationId === value)),
);
}
});
this.formGroup.controls.vaultSelector.setValue("myVault");
} }
this.formGroup.controls.format.valueChanges this.formGroup.controls.format.valueChanges
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((value) => { .subscribe((value) => {
@ -244,10 +222,58 @@ export class ImportComponent implements OnInit, OnDestroy {
await this.handlePolicies(); await this.handlePolicies();
} }
private handleOrganizationImportInit() {
this.formGroup.controls.vaultSelector.patchValue(this.organizationId);
this.formGroup.controls.vaultSelector.disable();
this.collections$ = Utils.asyncToObservable(() =>
this.importCollectionService
.getAllAdminCollections(this.organizationId)
.then((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
);
this._isFromAC = true;
}
private handleImportInit() {
// Filter out the no folder-item from folderViews$
this.folders$ = this.folderService.folderViews$.pipe(
map((folders) => folders.filter((f) => f.id != null)),
);
this.formGroup.controls.targetSelector.disable();
combineLatest([this.formGroup.controls.vaultSelector.valueChanges, this.organizations$])
.pipe(takeUntil(this.destroy$))
.subscribe(([value, organizations]) => {
this.organizationId = value !== "myVault" ? value : undefined;
if (!this._importBlockedByPolicy) {
this.formGroup.controls.targetSelector.enable();
}
const flexCollectionEnabled =
organizations.find((x) => x.id == this.organizationId)?.flexibleCollections ?? false;
if (value) {
this.collections$ = Utils.asyncToObservable(() =>
this.collectionService
.getAllDecrypted()
.then((decryptedCollections) =>
decryptedCollections
.filter(
(c2) => c2.organizationId === value && (!flexCollectionEnabled || c2.manage),
)
.sort(Utils.getSortFunction(this.i18nService, "name")),
),
);
}
});
this.formGroup.controls.vaultSelector.setValue("myVault");
}
private async initializeOrganizations() { private async initializeOrganizations() {
this.organizations$ = concat( this.organizations$ = concat(
this.organizationService.memberOrganizations$.pipe( this.organizationService.memberOrganizations$.pipe(
canAccessImportExport(this.i18nService), canAccessImport(this.i18nService),
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))), map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
), ),
); );
@ -293,24 +319,7 @@ export class ImportComponent implements OnInit, OnDestroy {
} }
protected async performImport() { protected async performImport() {
if (this.organization) { if (!(await this.validateImport())) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "importWarning", placeholders: [this.organization.name] },
type: "warning",
});
if (!confirmed) {
return;
}
}
if (this.importBlockedByPolicy && this.organizationId == null) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("personalOwnershipPolicyInEffectImports"),
);
return; return;
} }
@ -333,10 +342,9 @@ export class ImportComponent implements OnInit, OnDestroy {
return; return;
} }
const fileEl = document.getElementById("import_input_file") as HTMLInputElement; const importContents = await this.setImportContents();
const files = fileEl.files;
let fileContents = this.formGroup.controls.fileContents.value; if (importContents == null || importContents === "") {
if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
this.i18nService.t("errorOccurred"), this.i18nService.t("errorOccurred"),
@ -345,37 +353,13 @@ export class ImportComponent implements OnInit, OnDestroy {
return; return;
} }
if (files != null && files.length > 0) {
try {
const content = await this.getFileContents(files[0]);
if (content != null) {
fileContents = content;
}
} catch (e) {
this.logService.error(e);
}
}
if (fileContents == null || fileContents === "") {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectFile"),
);
return;
}
if (this.organizationId) {
await this.organizationService.get(this.organizationId)?.isAdmin;
}
try { try {
const result = await this.importService.import( const result = await this.importService.import(
importer, importer,
fileContents, importContents,
this.organizationId, this.organizationId,
this.formGroup.controls.targetSelector.value, this.formGroup.controls.targetSelector.value,
this.canAccessImportExport(this.organizationId), this.canAccessImportExport(this.organizationId) && this._isFromAC,
); );
//No errors, display success message //No errors, display success message
@ -393,13 +377,6 @@ export class ImportComponent implements OnInit, OnDestroy {
} }
} }
private isUserAdmin(organizationId?: string): boolean {
if (!organizationId) {
return false;
}
return this.organizationService.get(this.organizationId)?.isAdmin;
}
private canAccessImportExport(organizationId?: string): boolean { private canAccessImportExport(organizationId?: string): boolean {
if (!organizationId) { if (!organizationId) {
return false; return false;
@ -507,6 +484,58 @@ export class ImportComponent implements OnInit, OnDestroy {
return await lastValueFrom(dialog.closed); return await lastValueFrom(dialog.closed);
} }
private async validateImport(): Promise<boolean> {
if (this.organization) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "importWarning", placeholders: [this.organization.name] },
type: "warning",
});
if (!confirmed) {
return false;
}
}
if (this.importBlockedByPolicy && this.organizationId == null) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("personalOwnershipPolicyInEffectImports"),
);
return false;
}
return true;
}
private async setImportContents(): Promise<string> {
const fileEl = document.getElementById("import_input_file") as HTMLInputElement;
const files = fileEl.files;
let fileContents = this.formGroup.controls.fileContents.value;
if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectFile"),
);
return;
}
if (files != null && files.length > 0) {
try {
const content = await this.getFileContents(files[0]);
if (content != null) {
fileContents = content;
}
} catch (e) {
this.logService.error(e);
}
}
return fileContents;
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();

View File

@ -0,0 +1,5 @@
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
export abstract class ImportCollectionServiceAbstraction {
getAllAdminCollections: (organizationId: string) => Promise<CollectionView[]>;
}

View File

@ -152,9 +152,8 @@ export class ImportService implements ImportServiceAbstraction {
Utils.isNullOrWhitespace(selectedImportTarget) && Utils.isNullOrWhitespace(selectedImportTarget) &&
!canAccessImportExport !canAccessImportExport
) { ) {
const hasUnassignedCollections = importResult.ciphers.some( const hasUnassignedCollections =
(c) => !Array.isArray(c.collectionIds) || c.collectionIds.length == 0, importResult.collectionRelationships.length < importResult.ciphers.length;
);
if (hasUnassignedCollections) { if (hasUnassignedCollections) {
throw new Error(this.i18nService.t("importUnassignedItemsError")); throw new Error(this.i18nService.t("importUnassignedItemsError"));
} }

View File

@ -3,3 +3,5 @@ export { ImportApiService } from "./import-api.service";
export { ImportServiceAbstraction } from "./import.service.abstraction"; export { ImportServiceAbstraction } from "./import.service.abstraction";
export { ImportService } from "./import.service"; export { ImportService } from "./import.service";
export { ImportCollectionServiceAbstraction } from "./import-collection.service.abstraction";