SM-365: Add SM Import & Export (#4323)
* SM-365: Add scaffolding for settings, import, and export components * SM-365: Build out SM export component and retrieve org name * Add password verification * Add SMExportService * SM-365: Add full export functionality for client side * SM-365: Add SM Import UI, combine import & export services, general cleanup * SM-365: Small updates, fix settings navigation for SM * SM-365: Refactorings based on PR comments, part 1 * SM-365: Refactorings based on PR comments, part 2 * SM-365: remove unneeded import file parsing code * Attempt New SM Export Auth Flow (#4596) * Attempt new sm-export auth flow * Fix component * SM-365: Add error messaging for failed import * SM-365: Fix import error dialog * SM-365: Fix layout of pages, title, and success messaging * SM-365: Address majority of PR comments, clear import form on success * SM-365: Refactor error handling, refactor date formatting * SM-365: Refactored names, logic, added SM porting api service, added needed error checking, etc. * SM-365: Refactor fileContents to pastedContents to be more clear * SM-365: Refactoring based on PR comments * SM-365: Update based on PR comments, refactoring ngOnInit for sm-import * SM-365: Fix wrong type on choose import file button
This commit is contained in:
parent
d65acc3bad
commit
63563bd87d
|
@ -1249,6 +1249,9 @@
|
|||
"importSuccess": {
|
||||
"message": "Data successfully imported"
|
||||
},
|
||||
"dataExportSuccess": {
|
||||
"message": "Data successfully exported"
|
||||
},
|
||||
"importWarning": {
|
||||
"message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?",
|
||||
"placeholders": {
|
||||
|
@ -6111,6 +6114,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"exportData": {
|
||||
"message": "Export data"
|
||||
},
|
||||
"exportingOrganizationSecretDataTitle": {
|
||||
"message": "Exporting Organization Secret Data"
|
||||
},
|
||||
"exportingOrganizationSecretDataDescription": {
|
||||
"message": "Only the Secrets Manager data associated with $ORGANIZATION$ will be exported. Items in other products or from other organizations will not be included.",
|
||||
"placeholders": {
|
||||
"ORGANIZATION": {
|
||||
"content": "$1",
|
||||
"example": "My Org Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileUpload": {
|
||||
"message": "File upload"
|
||||
},
|
||||
"acceptedFormats": {
|
||||
"message": "Accepted Formats:"
|
||||
},
|
||||
"copyPasteImportContents": {
|
||||
"message": "Copy & paste import contents:"
|
||||
},
|
||||
"or": {
|
||||
"message": "or"
|
||||
},
|
||||
"licenseAndBillingManagement": {
|
||||
"message": "License and billing management"
|
||||
},
|
||||
|
@ -6188,5 +6218,14 @@
|
|||
},
|
||||
"userAccessSecretsManager": {
|
||||
"message": "This user can access the Secrets Manager Beta"
|
||||
},
|
||||
"resolveTheErrorsBelowAndTryAgain": {
|
||||
"message": "Resolve the errors below and try again."
|
||||
},
|
||||
"description": {
|
||||
"message": "Description"
|
||||
},
|
||||
"errorReadingImportFile": {
|
||||
"message": "An error occurred when trying to read the import file"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,4 +11,7 @@
|
|||
route="service-accounts"
|
||||
></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-trash" [text]="'trash' | i18n" route="trash"></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-cog" [text]="'settings' | i18n" route="settings"></bit-nav-item>
|
||||
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n">
|
||||
<bit-nav-item [text]="'importData' | i18n" route="settings/import"></bit-nav-item>
|
||||
<bit-nav-item [text]="'exportData' | i18n" route="settings/export"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<bit-dialog>
|
||||
<span bitDialogTitle>
|
||||
{{ "importError" | i18n }}
|
||||
</span>
|
||||
<span bitDialogContent>
|
||||
<div>{{ "resolveTheErrorsBelowAndTryAgain" | i18n }}</div>
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "description" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let line of errorLines">
|
||||
<td bitCell class="tw-break-all">[{{ line.id }}] [{{ line.type }}] {{ line.key }}</td>
|
||||
<td bitCell>{{ line.errorMessage }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</span>
|
||||
<div bitDialogFooter>
|
||||
<button bitButton bitDialogClose buttonType="primary" type="button">
|
||||
{{ "ok" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-dialog>
|
|
@ -0,0 +1,27 @@
|
|||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { SecretsManagerImportError } from "../models/error/sm-import-error";
|
||||
import { SecretsManagerImportErrorLine } from "../models/error/sm-import-error-line";
|
||||
|
||||
export interface SecretsManagerImportErrorDialogOperation {
|
||||
error: SecretsManagerImportError;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "sm-import-error-dialog",
|
||||
templateUrl: "./sm-import-error-dialog.component.html",
|
||||
})
|
||||
export class SecretsManagerImportErrorDialogComponent {
|
||||
errorLines: SecretsManagerImportErrorLine[];
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
private i18nService: I18nService,
|
||||
@Inject(DIALOG_DATA) public data: SecretsManagerImportErrorDialogOperation
|
||||
) {
|
||||
this.errorLines = data.error.lines;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export class SecretsManagerImportErrorLine {
|
||||
id: number;
|
||||
type: "Project" | "Secret";
|
||||
key: "string";
|
||||
errorMessage: string;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { SecretsManagerImportErrorLine } from "./sm-import-error-line";
|
||||
|
||||
export class SecretsManagerImportError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
lines: SecretsManagerImportErrorLine[];
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { SecretsManagerImportedProjectRequest } from "./sm-imported-project.request";
|
||||
import { SecretsManagerImportedSecretRequest } from "./sm-imported-secret.request";
|
||||
|
||||
export class SecretsManagerImportRequest {
|
||||
projects: SecretsManagerImportedProjectRequest[];
|
||||
secrets: SecretsManagerImportedSecretRequest[];
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class SecretsManagerImportedProjectRequest {
|
||||
id: string;
|
||||
name: EncString;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class SecretsManagerImportedSecretRequest {
|
||||
id: string;
|
||||
key: EncString;
|
||||
value: EncString;
|
||||
note: EncString;
|
||||
projectIds: string[];
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
import { SecretsManagerExportedProjectResponse } from "./sm-exported-project.response";
|
||||
import { SecretsManagerExportedSecretResponse } from "./sm-exported-secret.response";
|
||||
|
||||
export class SecretsManagerExportResponse extends BaseResponse {
|
||||
projects: SecretsManagerExportedProjectResponse[];
|
||||
secrets: SecretsManagerExportedSecretResponse[];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const projects = this.getResponseProperty("Projects");
|
||||
const secrets = this.getResponseProperty("Secrets");
|
||||
|
||||
this.projects = projects?.map((k: any) => new SecretsManagerExportedProjectResponse(k));
|
||||
this.secrets = secrets?.map((k: any) => new SecretsManagerExportedSecretResponse(k));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class SecretsManagerExportedProjectResponse extends BaseResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class SecretsManagerExportedSecretResponse extends BaseResponse {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
note: string;
|
||||
projectIds: string[];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
this.value = this.getResponseProperty("Value");
|
||||
this.note = this.getResponseProperty("Note");
|
||||
|
||||
const projectIds = this.getResponseProperty("ProjectIds");
|
||||
this.projectIds = projectIds?.map((id: any) => id.toString());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
export class SecretsManagerExport {
|
||||
projects: SecretsManagerExportProject[];
|
||||
secrets: SecretsManagerExportSecret[];
|
||||
}
|
||||
|
||||
export class SecretsManagerExportProject {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class SecretsManagerExportSecret {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
note: string;
|
||||
projectIds: string[];
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
<sm-header></sm-header>
|
||||
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-my-4 tw-max-w-xl">
|
||||
<app-callout type="info" title="{{ 'exportingOrganizationSecretDataTitle' | i18n }}">
|
||||
{{ "exportingOrganizationSecretDataDescription" | i18n: orgName }}
|
||||
</app-callout>
|
||||
</div>
|
||||
|
||||
<bit-form-field class="tw-max-w-sm">
|
||||
<bit-label>{{ "fileFormat" | i18n }}</bit-label>
|
||||
<select bitInput formControlName="format">
|
||||
<option *ngFor="let format of exportFormats" [ngValue]="format">{{ format }}</option>
|
||||
</select>
|
||||
</bit-form-field>
|
||||
|
||||
<button bitButton bitFormButton type="submit" buttonType="primary">
|
||||
{{ "exportData" | i18n }}
|
||||
</button>
|
||||
</form>
|
|
@ -0,0 +1,117 @@
|
|||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { UserVerificationPromptComponent } from "@bitwarden/web-vault/app/components/user-verification-prompt.component";
|
||||
|
||||
import { SecretsManagerPortingApiService } from "../services/sm-porting-api.service";
|
||||
import { SecretsManagerPortingService } from "../services/sm-porting.service";
|
||||
|
||||
@Component({
|
||||
selector: "sm-export",
|
||||
templateUrl: "./sm-export.component.html",
|
||||
})
|
||||
export class SecretsManagerExportComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected orgName: string;
|
||||
protected orgId: string;
|
||||
protected exportFormats: string[] = ["json"];
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
format: new FormControl("json", [Validators.required]),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private smPortingService: SecretsManagerPortingService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private logService: LogService,
|
||||
private modalService: ModalService,
|
||||
private secretsManagerApiService: SecretsManagerPortingApiService
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.params
|
||||
.pipe(
|
||||
switchMap(async (params) => await this.organizationService.get(params.organizationId)),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((organization) => {
|
||||
this.orgName = organization.name;
|
||||
this.orgId = organization.id;
|
||||
});
|
||||
|
||||
this.formGroup.get("format").disable();
|
||||
}
|
||||
|
||||
async ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userVerified = await this.verifyUser();
|
||||
if (!userVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.doExport();
|
||||
};
|
||||
|
||||
private async doExport() {
|
||||
try {
|
||||
const exportData = await this.secretsManagerApiService.export(
|
||||
this.orgId,
|
||||
this.formGroup.get("format").value
|
||||
);
|
||||
|
||||
await this.downloadFile(exportData, this.formGroup.get("format").value);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("dataExportSuccess"));
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(data: string, format: string) {
|
||||
const fileName = await this.smPortingService.getFileName(null, format);
|
||||
this.fileDownloadService.download({
|
||||
fileName: fileName,
|
||||
blobData: data,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
private verifyUser() {
|
||||
const ref = this.modalService.open(UserVerificationPromptComponent, {
|
||||
allowMultipleModals: true,
|
||||
data: {
|
||||
confirmDescription: "exportWarningDesc",
|
||||
confirmButtonText: "exportVault",
|
||||
modalTitle: "confirmVaultExport",
|
||||
},
|
||||
});
|
||||
|
||||
if (ref == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return ref.onClosedPromise();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
<sm-header></sm-header>
|
||||
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-max-w-xl">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "fileUpload" | i18n }}</bit-label>
|
||||
<div class="file-selector">
|
||||
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
|
||||
{{ "chooseFile" | i18n }}
|
||||
</button>
|
||||
{{ selectedFile?.name ?? ("noFileChosen" | i18n) }}
|
||||
</div>
|
||||
<input
|
||||
#fileSelector
|
||||
hidden
|
||||
bitInput
|
||||
type="file"
|
||||
id="file"
|
||||
class="form-control-file"
|
||||
name="file"
|
||||
(change)="setSelectedFile($event)"
|
||||
accept="application/JSON"
|
||||
/>
|
||||
<bit-hint>{{ "acceptedFormats" | i18n }} JSON</bit-hint>
|
||||
</bit-form-field>
|
||||
<div class="my-4">
|
||||
{{ "or" | i18n }}
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label for="pastedContents">{{ "copyPasteImportContents" | i18n }}</bit-label>
|
||||
<textarea
|
||||
bitInput
|
||||
id="pastedContents"
|
||||
class="form-control"
|
||||
name="FileContents"
|
||||
formControlName="pastedContents"
|
||||
></textarea>
|
||||
<bit-hint>{{ "acceptedFormats" | i18n }} JSON</bit-hint>
|
||||
</bit-form-field>
|
||||
<button bitButton bitformButton type="submit" buttonType="primary">
|
||||
{{ "importData" | i18n }}
|
||||
</button>
|
||||
</form>
|
|
@ -0,0 +1,166 @@
|
|||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
SecretsManagerImportErrorDialogComponent,
|
||||
SecretsManagerImportErrorDialogOperation,
|
||||
} from "../dialog/sm-import-error-dialog.component";
|
||||
import { SecretsManagerImportError } from "../models/error/sm-import-error";
|
||||
import { SecretsManagerPortingApiService } from "../services/sm-porting-api.service";
|
||||
|
||||
@Component({
|
||||
selector: "sm-import",
|
||||
templateUrl: "./sm-import.component.html",
|
||||
})
|
||||
export class SecretsManagerImportComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected orgId: string = null;
|
||||
protected selectedFile: File;
|
||||
protected formGroup = new FormGroup({
|
||||
pastedContents: new FormControl(""),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
protected fileDownloadService: FileDownloadService,
|
||||
private logService: LogService,
|
||||
private secretsManagerPortingApiService: SecretsManagerPortingApiService,
|
||||
private dialogService: DialogService
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
this.orgId = params.organizationId;
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
const fileElement = document.getElementById("file") as HTMLInputElement;
|
||||
const importContents = await this.getImportContents(
|
||||
fileElement,
|
||||
this.formGroup.get("pastedContents").value.trim()
|
||||
);
|
||||
|
||||
if (importContents == null) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("selectFile")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const error = await this.secretsManagerPortingApiService.import(this.orgId, importContents);
|
||||
|
||||
if (error?.lines?.length > 0) {
|
||||
this.openImportErrorDialog(error);
|
||||
return;
|
||||
} else if (error != null) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("errorReadingImportFile")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("importSuccess"));
|
||||
this.clearForm();
|
||||
} catch (error) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("errorReadingImportFile")
|
||||
);
|
||||
this.logService.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
protected async getImportContents(
|
||||
fileElement: HTMLInputElement,
|
||||
pastedContents: string
|
||||
): Promise<string> {
|
||||
const files = fileElement.files;
|
||||
|
||||
if (
|
||||
(files == null || files.length === 0) &&
|
||||
(pastedContents == null || pastedContents === "")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fileContents = pastedContents;
|
||||
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 === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileContents;
|
||||
}
|
||||
|
||||
protected setSelectedFile(event: Event) {
|
||||
const fileInputEl = <HTMLInputElement>event.target;
|
||||
const file = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
|
||||
this.selectedFile = file;
|
||||
}
|
||||
|
||||
private clearForm() {
|
||||
(document.getElementById("file") as HTMLInputElement).value = "";
|
||||
this.selectedFile = null;
|
||||
this.formGroup.reset({
|
||||
pastedContents: "",
|
||||
});
|
||||
}
|
||||
|
||||
private getFileContents(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, "utf-8");
|
||||
reader.onload = (evt) => {
|
||||
resolve((evt.target as any).result);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private openImportErrorDialog(error: SecretsManagerImportError) {
|
||||
this.dialogService.open<unknown, SecretsManagerImportErrorDialogOperation>(
|
||||
SecretsManagerImportErrorDialogComponent,
|
||||
{
|
||||
data: {
|
||||
error: error,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
|
||||
import { SecretsManagerImportError } from "../models/error/sm-import-error";
|
||||
import { SecretsManagerImportRequest } from "../models/requests/sm-import.request";
|
||||
import { SecretsManagerImportedProjectRequest } from "../models/requests/sm-imported-project.request";
|
||||
import { SecretsManagerImportedSecretRequest } from "../models/requests/sm-imported-secret.request";
|
||||
import { SecretsManagerExportResponse } from "../models/responses/sm-export.response";
|
||||
import {
|
||||
SecretsManagerExport,
|
||||
SecretsManagerExportProject,
|
||||
SecretsManagerExportSecret,
|
||||
} from "../models/sm-export";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SecretsManagerPortingApiService {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoService: CryptoService,
|
||||
private i18nService: I18nService
|
||||
) {}
|
||||
|
||||
async export(organizationId: string, exportFormat = "json"): Promise<string> {
|
||||
let response = {};
|
||||
|
||||
try {
|
||||
response = await this.apiService.send(
|
||||
"GET",
|
||||
"/sm/" + organizationId + "/export?format=" + exportFormat,
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
await this.decryptExport(organizationId, new SecretsManagerExportResponse(response)),
|
||||
null,
|
||||
" "
|
||||
);
|
||||
}
|
||||
|
||||
async import(organizationId: string, fileContents: string): Promise<SecretsManagerImportError> {
|
||||
let requestObject = {};
|
||||
|
||||
try {
|
||||
requestObject = JSON.parse(fileContents);
|
||||
const requestBody = await this.encryptImport(organizationId, requestObject);
|
||||
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
"/sm/" + organizationId + "/import",
|
||||
requestBody,
|
||||
true,
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
const errorResponse = new ErrorResponse(error, 400);
|
||||
return this.handleServerError(errorResponse, requestObject);
|
||||
}
|
||||
}
|
||||
|
||||
private async encryptImport(
|
||||
organizationId: string,
|
||||
importData: any
|
||||
): Promise<SecretsManagerImportRequest> {
|
||||
const encryptedImport = new SecretsManagerImportRequest();
|
||||
|
||||
try {
|
||||
const orgKey = await this.cryptoService.getOrgKey(organizationId);
|
||||
encryptedImport.projects = [];
|
||||
encryptedImport.secrets = [];
|
||||
|
||||
encryptedImport.projects = await Promise.all(
|
||||
importData.projects.map(async (p: any) => {
|
||||
const project = new SecretsManagerImportedProjectRequest();
|
||||
project.id = p.id;
|
||||
project.name = await this.encryptService.encrypt(p.name, orgKey);
|
||||
return project;
|
||||
})
|
||||
);
|
||||
|
||||
encryptedImport.secrets = await Promise.all(
|
||||
importData.secrets.map(async (s: any) => {
|
||||
const secret = new SecretsManagerImportedSecretRequest();
|
||||
|
||||
[secret.key, secret.value, secret.note] = await Promise.all([
|
||||
this.encryptService.encrypt(s.key, orgKey),
|
||||
this.encryptService.encrypt(s.value, orgKey),
|
||||
this.encryptService.encrypt(s.note, orgKey),
|
||||
]);
|
||||
|
||||
secret.id = s.id;
|
||||
secret.projectIds = s.projectIds;
|
||||
|
||||
return secret;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return encryptedImport;
|
||||
}
|
||||
|
||||
private async decryptExport(
|
||||
organizationId: string,
|
||||
exportData: SecretsManagerExportResponse
|
||||
): Promise<SecretsManagerExport> {
|
||||
const orgKey = await this.cryptoService.getOrgKey(organizationId);
|
||||
const decryptedExport = new SecretsManagerExport();
|
||||
decryptedExport.projects = [];
|
||||
decryptedExport.secrets = [];
|
||||
|
||||
decryptedExport.projects = await Promise.all(
|
||||
exportData.projects.map(async (p) => {
|
||||
const project = new SecretsManagerExportProject();
|
||||
project.id = p.id;
|
||||
project.name = await this.encryptService.decryptToUtf8(new EncString(p.name), orgKey);
|
||||
return project;
|
||||
})
|
||||
);
|
||||
|
||||
decryptedExport.secrets = await Promise.all(
|
||||
exportData.secrets.map(async (s) => {
|
||||
const secret = new SecretsManagerExportSecret();
|
||||
|
||||
[secret.key, secret.value, secret.note] = await Promise.all([
|
||||
this.encryptService.decryptToUtf8(new EncString(s.key), orgKey),
|
||||
this.encryptService.decryptToUtf8(new EncString(s.value), orgKey),
|
||||
this.encryptService.decryptToUtf8(new EncString(s.note), orgKey),
|
||||
]);
|
||||
|
||||
secret.id = s.id;
|
||||
secret.projectIds = s.projectIds;
|
||||
|
||||
return secret;
|
||||
})
|
||||
);
|
||||
|
||||
return decryptedExport;
|
||||
}
|
||||
|
||||
private handleServerError(
|
||||
errorResponse: ErrorResponse,
|
||||
importResult: any
|
||||
): SecretsManagerImportError {
|
||||
if (errorResponse.validationErrors == null) {
|
||||
return new SecretsManagerImportError(errorResponse.message);
|
||||
}
|
||||
|
||||
const result = new SecretsManagerImportError();
|
||||
result.lines = [];
|
||||
|
||||
Object.entries(errorResponse.validationErrors).forEach(([key, value], index) => {
|
||||
let item;
|
||||
let itemType;
|
||||
const id = Number(key.match(/[0-9]+/)[0]);
|
||||
|
||||
switch (key.match(/^\w+/)[0]) {
|
||||
case "Projects":
|
||||
item = importResult.projects[id];
|
||||
itemType = "Project";
|
||||
break;
|
||||
case "Secrets":
|
||||
item = importResult.secrets[id];
|
||||
itemType = "Secret";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
result.lines.push({
|
||||
id: id + 1,
|
||||
type: itemType == "Project" ? "Project" : "Secret",
|
||||
key: item.key,
|
||||
errorMessage: value.length > 0 ? value[0] : "",
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { formatDate } from "@angular/common";
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SecretsManagerPortingService {
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
async getFileName(prefix: string = null, extension = "json"): Promise<string> {
|
||||
const locale = await firstValueFrom(this.i18nService.locale$);
|
||||
const dateString = formatDate(new Date(), "yyyyMMddHHmmss", locale);
|
||||
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { SecretsManagerExportComponent } from "./porting/sm-export.component";
|
||||
import { SecretsManagerImportComponent } from "./porting/sm-import.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: "import",
|
||||
component: SecretsManagerImportComponent,
|
||||
data: {
|
||||
titleId: "importData",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "export",
|
||||
component: SecretsManagerExportComponent,
|
||||
data: {
|
||||
titleId: "exportData",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SettingsRoutingModule {}
|
|
@ -0,0 +1,21 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
|
||||
|
||||
import { SecretsManagerImportErrorDialogComponent } from "./dialog/sm-import-error-dialog.component";
|
||||
import { SecretsManagerExportComponent } from "./porting/sm-export.component";
|
||||
import { SecretsManagerImportComponent } from "./porting/sm-import.component";
|
||||
import { SecretsManagerPortingApiService } from "./services/sm-porting-api.service";
|
||||
import { SecretsManagerPortingService } from "./services/sm-porting.service";
|
||||
import { SettingsRoutingModule } from "./settings-routing.module";
|
||||
|
||||
@NgModule({
|
||||
imports: [SecretsManagerSharedModule, SettingsRoutingModule],
|
||||
declarations: [
|
||||
SecretsManagerImportComponent,
|
||||
SecretsManagerExportComponent,
|
||||
SecretsManagerImportErrorDialogComponent,
|
||||
],
|
||||
providers: [SecretsManagerPortingService, SecretsManagerPortingApiService],
|
||||
})
|
||||
export class SettingsModule {}
|
|
@ -11,6 +11,7 @@ import { OverviewModule } from "./overview/overview.module";
|
|||
import { ProjectsModule } from "./projects/projects.module";
|
||||
import { SecretsModule } from "./secrets/secrets.module";
|
||||
import { ServiceAccountsModule } from "./service-accounts/service-accounts.module";
|
||||
import { SettingsModule } from "./settings/settings.module";
|
||||
import { SMGuard } from "./sm.guard";
|
||||
|
||||
const routes: Routes = [
|
||||
|
@ -48,6 +49,10 @@ const routes: Routes = [
|
|||
titleId: "serviceAccounts",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
loadChildren: () => SettingsModule,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
loadChildren: () => OverviewModule,
|
||||
|
|
|
@ -5,7 +5,8 @@ export type InputTypes =
|
|||
| "datetime-local"
|
||||
| "email"
|
||||
| "checkbox"
|
||||
| "search";
|
||||
| "search"
|
||||
| "file";
|
||||
|
||||
export abstract class BitFormFieldControl {
|
||||
ariaDescribedBy: string;
|
||||
|
|
Loading…
Reference in New Issue