diff --git a/app/app.module.ts b/app/app.module.ts index 8bffdf0579..70b3fe8684 100644 --- a/app/app.module.ts +++ b/app/app.module.ts @@ -17,6 +17,7 @@ import { MainNavigationComponent } from "app/components/shared/main-navigation.c import { BaseModule } from "@batch-flask/ui"; import { AccountModule } from "app/components/account/account.module"; import { ApplicationModule } from "app/components/application/application.module"; +import { CertificateModule } from "app/components/certificate/certificate.module"; import { DataModule } from "app/components/data/data.module"; import { FileModule } from "app/components/file/file.module"; import { JobScheduleModule } from "app/components/job-schedule/job-schedule.module"; @@ -52,6 +53,7 @@ import { BatchClientService, BatchLabsService, CacheDataService, + CertificateService, CommandService, ComputeService, FileService, @@ -89,7 +91,7 @@ import { } from "./services"; const modules = [ - AccountModule, ApplicationModule, DataModule, + AccountModule, ApplicationModule, CertificateModule, DataModule, FileModule, JobModule, JobScheduleModule, NodeModule, PoolModule, SettingsModule, TaskModule, MarketModule, LayoutModule, MiscModule, @@ -134,6 +136,7 @@ const graphApiServices = [AADApplicationService, AADGraphHttpService, MsGraphHtt BatchClientService, BatchLabsService, CacheDataService, + CertificateService, CommandService, CommonModule, ComputeService, diff --git a/app/app.routes.ts b/app/app.routes.ts index e17a44a8cb..f1cac6a104 100644 --- a/app/app.routes.ts +++ b/app/app.routes.ts @@ -15,6 +15,8 @@ import { AccountHomeComponent } from "./components/account/home/account-home.com import { AccountMonitoringHomeComponent } from "./components/account/monitoring"; import { ApplicationDefaultComponent, ApplicationDetailsComponent } from "./components/application/details"; import { ApplicationHomeComponent } from "./components/application/home/application-home.component"; +import { CertificateDefaultComponent, CertificateDetailsComponent } from "./components/certificate/details"; +import { CertificateHomeComponent } from "./components/certificate/home/certificate-home.component"; import { DataDefaultComponent, DataDetailsComponent } from "./components/data/details"; import { DataHomeComponent } from "./components/data/home/data-home.component"; import { JobScheduleDefaultComponent, JobScheduleDetailsComponent } from "./components/job-schedule/details"; @@ -73,6 +75,15 @@ export const routes: Routes = [ { path: ":id", component: PoolDetailsComponent }, // pools/{pool.id} ], }, + { + path: "certificates", + canActivate: [NavigationGuard], + component: CertificateHomeComponent, + children: [ + { path: "", component: CertificateDefaultComponent }, // certificates/ + { path: ":thumbprint", component: CertificateDetailsComponent }, // certificate/{certificate.thumbprint} + ], + }, { path: "market", canActivate: [NavigationGuard], diff --git a/app/components/certificate/action/add/certificate-create-dialog.component.ts b/app/components/certificate/action/add/certificate-create-dialog.component.ts new file mode 100644 index 0000000000..2750fac66c --- /dev/null +++ b/app/components/certificate/action/add/certificate-create-dialog.component.ts @@ -0,0 +1,95 @@ +import { Component } from "@angular/core"; +import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { Observable } from "rxjs"; + +import { autobind } from "@batch-flask/core"; +import { NotificationService } from "@batch-flask/ui/notifications"; +import { SidebarRef } from "@batch-flask/ui/sidebar"; +import { CertificateFormat, CertificateService } from "app/services"; +import { Constants, FileUrlUtils } from "app/utils"; + +import "./certificate-create-dialog.scss"; + +@Component({ + selector: "bl-certificate-create-dialog", + templateUrl: "certificate-create-dialog.html", +}) +export class CertificateCreateDialogComponent { + public file: File; + public form: FormGroup; + + constructor( + private formBuilder: FormBuilder, + public sidebarRef: SidebarRef, + private certificateService: CertificateService, + private notificationService: NotificationService) { + const validation = Constants.forms.validation; + + this.form = this.formBuilder.group({ + certificate: ["", [ + Validators.required, + Validators.pattern(validation.regex.certificateFileName), + ]], + password: [null, this._passwordValidator()], + }); + } + + @autobind() + public submit(): Observable { + const formData = this.form.value; + const obs = this.certificateService.parseCertificate(this.file, formData.password); + + obs.subscribe({ + next: (certificate: any) => { + const obs = this.certificateService.add(certificate); + obs.subscribe({ + next: () => { + this.certificateService.onCertificateAdded.next(certificate.thumbprint); + this.notificationService.success("Certificate added!", + `Certificate '${certificate.thumbprint}' was created successfully!`); + }, + error: () => null, + }); + }, + error: (response: Response) => { + this.notificationService.error( + "Certificate creation failed", + response.toString(), + ); + }, + }); + return obs; + } + + public fileSelected(changeEvent: Event) { + const element = changeEvent.srcElement as any; + this.form.controls["certificate"].markAsTouched(); + if (element.files.length > 0) { + this.file = element.files[0]; + this.form.controls["certificate"].setValue(this.file.name); + } else { + this.file = null; + this.form.controls["certificate"].setValue(null); + } + this.form.controls["password"].setValue(null); + } + + public get showPassword() { + return this.file && FileUrlUtils.getFileExtension(this.file.name) === CertificateFormat.pfx; + } + + private _passwordValidator() { + return (control: FormControl): {[key: string]: any} => { + if (this.showPassword) { + if (!control.value) { + return { + pfxPasswordRequired: { + value: true, + }, + }; + } + } + return null; + }; + } +} diff --git a/app/components/certificate/action/add/certificate-create-dialog.html b/app/components/certificate/action/add/certificate-create-dialog.html new file mode 100644 index 0000000000..4aca449768 --- /dev/null +++ b/app/components/certificate/action/add/certificate-create-dialog.html @@ -0,0 +1,19 @@ + + + +
+ + Please select a valid certificate file + Certificate need to be valid CER or PFX files only +
+
+ +
+ + + + Password is required if the certificate format is pfx +
+
+
+
diff --git a/app/components/certificate/action/add/certificate-create-dialog.scss b/app/components/certificate/action/add/certificate-create-dialog.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/components/certificate/action/add/index.ts b/app/components/certificate/action/add/index.ts new file mode 100644 index 0000000000..58c8a7b0a7 --- /dev/null +++ b/app/components/certificate/action/add/index.ts @@ -0,0 +1 @@ +export * from "./certificate-create-dialog.component"; diff --git a/app/components/certificate/action/certificate-action.module.ts b/app/components/certificate/action/certificate-action.module.ts new file mode 100644 index 0000000000..c7d85081c6 --- /dev/null +++ b/app/components/certificate/action/certificate-action.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from "@angular/core"; + +import { BaseModule } from "@batch-flask/ui"; +import { commonModules } from "app/common"; +import { JobActionModule } from "app/components/job/action"; +import { CertificateCreateDialogComponent } from "./add/certificate-create-dialog.component"; +import { DeleteCertificateDialogComponent } from "./delete/delete-certificate-dialog.component"; +import { ReactivateCertificateDialogComponent } from "./reactivate/reactivate-certificate-dialog.component"; + +const components = [ + DeleteCertificateDialogComponent, ReactivateCertificateDialogComponent, CertificateCreateDialogComponent, +]; + +@NgModule({ + declarations: components, + exports: components, + imports: [...commonModules, JobActionModule, BaseModule], + entryComponents: components, +}) +export class CertificateActionModule { +} diff --git a/app/components/certificate/action/delete/delete-certificate-action.ts b/app/components/certificate/action/delete/delete-certificate-action.ts new file mode 100644 index 0000000000..6451eb85f9 --- /dev/null +++ b/app/components/certificate/action/delete/delete-certificate-action.ts @@ -0,0 +1,40 @@ +import { BehaviorSubject } from "rxjs"; + +import { BackgroundTaskService } from "@batch-flask/ui/background-task"; +import { WaitForDeletePoller } from "app/components/core/pollers"; +import { Certificate } from "app/models"; +import { CertificateService } from "app/services"; +import { LongRunningDeleteAction } from "app/services/core"; + +export class DeleteCertificateAction extends LongRunningDeleteAction { + constructor(private certificateService: CertificateService, certificateThumbprints: string[]) { + super("certificate", certificateThumbprints); + } + + public deleteAction(thumbprint: string) { + return this.certificateService.delete(thumbprint); + } + + protected waitForDelete(thumbprint: string, taskManager?: BackgroundTaskService) { + this.certificateService.get(thumbprint).subscribe({ + next: (certificate: Certificate) => { + const task = new WaitForDeletePoller(() => this.certificateService.get(thumbprint)); + if (taskManager) { + taskManager.startTask(`Deleting Certificate '${thumbprint}'`, (bTask) => { + return task.start(bTask.progress); + }); + } else { + task.start(new BehaviorSubject(-1)).subscribe({ + complete: () => { + this.markItemAsDeleted(); + }, + }); + } + }, + error: (error) => { + // No need to watch for Certificate it is already deleted + this.markItemAsDeleted(); + }, + }); + } +} diff --git a/app/components/certificate/action/delete/delete-certificate-dialog.component.ts b/app/components/certificate/action/delete/delete-certificate-dialog.component.ts new file mode 100644 index 0000000000..40f529a062 --- /dev/null +++ b/app/components/certificate/action/delete/delete-certificate-dialog.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from "@angular/core"; +import { MatDialogRef } from "@angular/material"; + +import { autobind } from "@batch-flask/core"; +import { BackgroundTaskService } from "@batch-flask/ui/background-task"; +import { DeleteCertificateAction } from "app/components/certificate/action/delete/delete-certificate-action"; +import { CertificateService } from "app/services"; + +@Component({ + selector: "bl-delete-certificate-dialog", + templateUrl: "delete-certificate-dialog.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteCertificateDialogComponent { + public set certificateThumbprint(certificateThumbprint: string) { + this._certificateThumbprint = certificateThumbprint; + this.changeDetector.detectChanges(); + } + public get certificateThumbprint() { return this._certificateThumbprint; } + + private _certificateThumbprint: string; + + constructor( + public dialogRef: MatDialogRef, + private certificateService: CertificateService, + private taskManager: BackgroundTaskService, + private changeDetector: ChangeDetectorRef) { + } + + @autobind() + public destroyCertificate() { + const task = new DeleteCertificateAction(this.certificateService, [this.certificateThumbprint]); + task.startAndWaitAsync(this.taskManager); + return task.actionDone; + } +} diff --git a/app/components/certificate/action/delete/delete-certificate-dialog.html b/app/components/certificate/action/delete/delete-certificate-dialog.html new file mode 100644 index 0000000000..a584757469 --- /dev/null +++ b/app/components/certificate/action/delete/delete-certificate-dialog.html @@ -0,0 +1,9 @@ + +
+

Do you want to delete the certificate: '{{certificateThumbprint}}'?

+

+ Warning! Deleting certificate'{{certificateThumbprint}}' is irreversible. The action you're about to take can't be undone. Clicking + OK will delete this certificate permanently. +

+
+
diff --git a/app/components/certificate/action/delete/index.ts b/app/components/certificate/action/delete/index.ts new file mode 100644 index 0000000000..e9b77fd569 --- /dev/null +++ b/app/components/certificate/action/delete/index.ts @@ -0,0 +1,2 @@ +export * from "./delete-certificate-action"; +export * from "./delete-certificate-dialog.component"; diff --git a/app/components/certificate/action/index.ts b/app/components/certificate/action/index.ts new file mode 100644 index 0000000000..0182406c6d --- /dev/null +++ b/app/components/certificate/action/index.ts @@ -0,0 +1,4 @@ + +export * from "./delete"; +export * from "./reactivate"; +export * from "./certificate-action.module"; diff --git a/app/components/certificate/action/reactivate/index.ts b/app/components/certificate/action/reactivate/index.ts new file mode 100644 index 0000000000..11ad64ff7e --- /dev/null +++ b/app/components/certificate/action/reactivate/index.ts @@ -0,0 +1 @@ +export * from "./reactivate-certificate-dialog.component"; diff --git a/app/components/certificate/action/reactivate/reactivate-certificate-dialog.component.ts b/app/components/certificate/action/reactivate/reactivate-certificate-dialog.component.ts new file mode 100644 index 0000000000..3b1d25d504 --- /dev/null +++ b/app/components/certificate/action/reactivate/reactivate-certificate-dialog.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { MatDialogRef } from "@angular/material"; + +import { autobind } from "@batch-flask/core"; +import { CertificateService } from "app/services"; + +@Component({ + selector: "bl-reactivate-certificate-dialog", + templateUrl: "reactivate-certificate-dialog.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReactivateCertificateDialogComponent { + public certificateThumbprint: string; + + constructor( + public dialogRef: MatDialogRef, + private certificateService: CertificateService) { + } + + @autobind() + public ok() { + const options: any = {}; + + return this.certificateService.cancelDelete(this.certificateThumbprint, options); + } +} diff --git a/app/components/certificate/action/reactivate/reactivate-certificate-dialog.html b/app/components/certificate/action/reactivate/reactivate-certificate-dialog.html new file mode 100644 index 0000000000..73415c91d1 --- /dev/null +++ b/app/components/certificate/action/reactivate/reactivate-certificate-dialog.html @@ -0,0 +1,3 @@ + +

Do you want to reactivate the certificate '{{certificateThumbprint}}'?

+
diff --git a/app/components/certificate/browse/certificate-list.component.ts b/app/components/certificate/browse/certificate-list.component.ts new file mode 100644 index 0000000000..ae703c7ae5 --- /dev/null +++ b/app/components/certificate/browse/certificate-list.component.ts @@ -0,0 +1,179 @@ +import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, forwardRef, +} from "@angular/core"; +import { FormControl } from "@angular/forms"; +import { MatDialog } from "@angular/material"; +import { ActivatedRoute, Router } from "@angular/router"; +import { List } from "immutable"; +import { Observable, Subscription } from "rxjs"; + +import { Filter, FilterMatcher, Operator, autobind } from "@batch-flask/core"; +import { ListBaseComponent, ListSelection } from "@batch-flask/core/list"; +import { BackgroundTaskService } from "@batch-flask/ui/background-task"; +import { ContextMenu, ContextMenuItem } from "@batch-flask/ui/context-menu"; +import { LoadingStatus } from "@batch-flask/ui/loading"; +import { QuickListItemStatus } from "@batch-flask/ui/quick-list"; +import { Certificate, CertificateState } from "app/models"; +import { CertificateListParams, CertificateService, PinnedEntityService } from "app/services"; +import { ListView } from "app/services/core"; +import { ComponentUtils } from "app/utils"; +import { + DeleteCertificateAction, DeleteCertificateDialogComponent, ReactivateCertificateDialogComponent, +} from "../action"; + +@Component({ + selector: "bl-certificate-list", + templateUrl: "certificate-list.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ + provide: ListBaseComponent, + useExisting: forwardRef(() => CertificateListComponent), + }], +}) +export class CertificateListComponent extends ListBaseComponent implements OnInit, OnDestroy { + public certificates: List; + public displayedCertificates: List = List([]); + public LoadingStatus = LoadingStatus; + public data: ListView; + public searchQuery = new FormControl(); + + private _onCertificateAddedSub: Subscription; + + constructor( + router: Router, + activatedRoute: ActivatedRoute, + changeDetector: ChangeDetectorRef, + private dialog: MatDialog, + private certificateService: CertificateService, + private pinnedEntityService: PinnedEntityService, + private taskManager: BackgroundTaskService, + ) { + super(changeDetector); + this.data = this.certificateService.listView(); + ComponentUtils.setActiveItem(activatedRoute, this.data); + this.data.items.subscribe((certificates) => { + this.certificates = certificates; + this._updateDisplayedCertificates(); + }); + + this.data.status.subscribe((status) => { + this.status = status; + }); + + this._onCertificateAddedSub = certificateService.onCertificateAdded.subscribe((certificateId) => { + this.data.loadNewItem(certificateService.get(certificateId)); + }); + } + + public ngOnInit() { + this.data.fetchNext(); + } + + public ngOnDestroy() { + if (this._onCertificateAddedSub) { + this._onCertificateAddedSub.unsubscribe(); + } + } + + @autobind() + public refresh(): Observable { + return this.data.refresh(); + } + + public handleFilter(filter: Filter) { + this._updateDisplayedCertificates(); + } + + public certificateStatus(certificate: Certificate): QuickListItemStatus { + switch (certificate.state) { + case CertificateState.active: + return QuickListItemStatus.normal; + case CertificateState.deletefailed: + return QuickListItemStatus.accent; + case CertificateState.deleting: + return QuickListItemStatus.important; + default: + return QuickListItemStatus.normal; + } + } + + public certificateStatusText(certificate: Certificate): string { + switch (certificate.state) { + case CertificateState.active: + return ""; + default: + return `Certificate is ${certificate.state}`; + } + } + + public onScrollToBottom() { + this.data.fetchNext(); + } + + public contextmenu(certificate: Certificate) { + const deletefailed = certificate.state === CertificateState.deletefailed; + return new ContextMenu([ + new ContextMenuItem({ label: "Delete", click: () => this.deleteCertificate(certificate) }), + new ContextMenuItem({ + label: "Reactivate", + click: () => this.reactivateCertificate(certificate), + enabled: deletefailed, + }), + new ContextMenuItem({ + label: this.pinnedEntityService.isFavorite(certificate) ? "Unpin favorite" : "Pin to favorites", + click: () => this._pinCertificate(certificate), + }), + ]); + } + + public deleteSelection(selection: ListSelection) { + this.taskManager.startTask("", (backgroundTask) => { + const task = new DeleteCertificateAction(this.certificateService, [...selection.keys]); + task.start(backgroundTask); + return task.waitingDone; + }); + } + + public deleteCertificate(certificate: Certificate) { + const dialogRef = this.dialog.open(DeleteCertificateDialogComponent); + dialogRef.componentInstance.certificateThumbprint = certificate.thumbprint; + dialogRef.afterClosed().subscribe((obj) => { + this.certificateService.get(certificate.thumbprint); + }); + } + + public reactivateCertificate(certificate: Certificate) { + const dialogRef = this.dialog.open(ReactivateCertificateDialogComponent); + dialogRef.componentInstance.certificateThumbprint = certificate.thumbprint; + dialogRef.afterClosed().subscribe((obj) => { + this.certificateService.get(certificate.thumbprint); + }); + } + + public trackByFn(index: number, certificate: Certificate) { + return certificate.thumbprint; + } + + private _pinCertificate(certificate: Certificate) { + this.pinnedEntityService.pinFavorite(certificate).subscribe((result) => { + if (result) { + this.pinnedEntityService.unPinFavorite(certificate); + } + }); + } + + private _updateDisplayedCertificates() { + const matcher = new FilterMatcher({ + thumbprint: (item: Certificate, value: any, operator: Operator) => { + return value === "" || item.thumbprint.toLowerCase().startsWith(value.toLowerCase()); + }, + state: (item: Certificate, value: any, operator: Operator) => { + return value === "" || item.state === value; + }, + }); + this.displayedCertificates = List(this.certificates.filter((x) => { + return matcher.test(this.filter, x); + })); + this.changeDetector.markForCheck(); + } +} diff --git a/app/components/certificate/browse/certificate-list.html b/app/components/certificate/browse/certificate-list.html new file mode 100644 index 0000000000..ae96efa6b7 --- /dev/null +++ b/app/components/certificate/browse/certificate-list.html @@ -0,0 +1,34 @@ + + + + + +
{{certificate.thumbprint}}
+
+ {{certificate.state}} +
+
+
+ + + + Thumbprint algorithm + Thumbprint + State + + + + {{certificate.thumbprintAlgorithm}} + {{certificate.thumbprint}} + {{certificate.state}} + + +
+ + + + + + No certificates + No certificates match this filter + diff --git a/app/components/certificate/browse/filter/certificate-advanced-filter.component.ts b/app/components/certificate/browse/filter/certificate-advanced-filter.component.ts new file mode 100644 index 0000000000..4d7ae37d32 --- /dev/null +++ b/app/components/certificate/browse/filter/certificate-advanced-filter.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from "@angular/core"; + +import { Filter } from "@batch-flask/core"; +import { AdvancedFilter, StatePickerControl } from "@batch-flask/ui/advanced-filter"; +import { CertificateState } from "app/models"; + +@Component({ + selector: "bl-certificate-advanced-filter", + templateUrl: "certificate-advanced-filter.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CertificateAdvancedFilterComponent { + @Output() public change = new EventEmitter(); + + public advancedFilter: AdvancedFilter; + + constructor() { + this.advancedFilter = new AdvancedFilter({ + state: new StatePickerControl("State", [ + CertificateState.active, + CertificateState.deletefailed, + CertificateState.deleting, + ]), + }); + + this.advancedFilter.filterChange.subscribe((filter: Filter) => { + this.change.emit(filter); + }); + } +} diff --git a/app/components/certificate/browse/filter/certificate-advanced-filter.html b/app/components/certificate/browse/filter/certificate-advanced-filter.html new file mode 100644 index 0000000000..73e31a7734 --- /dev/null +++ b/app/components/certificate/browse/filter/certificate-advanced-filter.html @@ -0,0 +1,7 @@ + + +

Certificate advanced filters

+
+ +
+
diff --git a/app/components/certificate/certificate.module.ts b/app/components/certificate/certificate.module.ts new file mode 100644 index 0000000000..be3c1fb216 --- /dev/null +++ b/app/components/certificate/certificate.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from "@angular/core"; + +import { commonModules } from "app/common"; +import { CertificateActionModule } from "./action"; +import { CertificateListComponent } from "./browse/certificate-list.component"; +import { CertificateAdvancedFilterComponent } from "./browse/filter/certificate-advanced-filter.component"; +import { CertificateDetailsModule } from "./details/certificate-details.module"; +import { CertificateHomeComponent } from "./home/certificate-home.component"; + +const components = [ + CertificateAdvancedFilterComponent, + CertificateHomeComponent, + CertificateListComponent, +]; + +const modules = [ + CertificateActionModule, CertificateDetailsModule, ...commonModules, +]; + +@NgModule({ + declarations: components, + exports: [...modules, ...components], + imports: [...modules], + entryComponents: [ + ], +}) +export class CertificateModule { +} diff --git a/app/components/certificate/details/certificate-configuration.component.ts b/app/components/certificate/details/certificate-configuration.component.ts new file mode 100644 index 0000000000..46afabdfe3 --- /dev/null +++ b/app/components/certificate/details/certificate-configuration.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; + +import { Certificate } from "app/models"; +import { CertificateDecorator } from "app/models/decorators"; + +// tslint:disable:trackBy-function +@Component({ + selector: "bl-certificate-configuration", + templateUrl: "certificate-configuration.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CertificateConfigurationComponent { + @Input() + public set certificate(certificate: Certificate) { + this._certificate = certificate; + this.refresh(certificate); + } + public get certificate() { return this._certificate; } + + public decorator: CertificateDecorator = { } as any; + + private _certificate: Certificate; + + public refresh(certificate: Certificate) { + if (this.certificate) { + this.decorator = new CertificateDecorator(this.certificate); + } + } +} diff --git a/app/components/certificate/details/certificate-configuration.html b/app/components/certificate/details/certificate-configuration.html new file mode 100644 index 0000000000..21b1d64acf --- /dev/null +++ b/app/components/certificate/details/certificate-configuration.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + +
Name
+
Value
+
+ + + + +
+
+
+
diff --git a/app/components/certificate/details/certificate-default.component.ts b/app/components/certificate/details/certificate-default.component.ts new file mode 100644 index 0000000000..a4b9e7bb4f --- /dev/null +++ b/app/components/certificate/details/certificate-default.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +@Component({ + selector: "bl-certificate-details-home", + template: ` +
+ +

Please select a certificate from the list

+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) + +export class CertificateDefaultComponent { + public static breadcrumb() { + return { name: "Certificates" }; + } +} diff --git a/app/components/certificate/details/certificate-details.component.ts b/app/components/certificate/details/certificate-details.component.ts new file mode 100644 index 0000000000..cff6881a95 --- /dev/null +++ b/app/components/certificate/details/certificate-details.component.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core"; +import { MatDialog, MatDialogConfig } from "@angular/material"; +import { ActivatedRoute, Router } from "@angular/router"; +import { autobind } from "@batch-flask/core"; +import { ElectronRemote } from "@batch-flask/ui"; +import { Observable, Subscription } from "rxjs"; + +import { Certificate, CertificateState } from "app/models"; +import { CertificateDecorator } from "app/models/decorators"; +import { CertificateParams, CertificateService, FileSystemService } from "app/services"; +import { EntityView } from "app/services/core"; +import { + DeleteCertificateDialogComponent, ReactivateCertificateDialogComponent, +} from "../action"; + +import "./certificate-details.scss"; + +@Component({ + selector: "bl-certificate-details", + templateUrl: "certificate-details.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CertificateDetailsComponent implements OnInit, OnDestroy { + public static breadcrumb({ thumbprint }) { + const label = `Certificate - ${thumbprint}`; + return { + name: thumbprint, + label, + icon: "certificate", + }; + } + + public certificateThumbprint: string; + public certificate: Certificate; + public decorator: CertificateDecorator; + public data: EntityView; + public CertificateState = CertificateState; + + private _paramsSubscriber: Subscription; + + constructor( + private dialog: MatDialog, + private activatedRoute: ActivatedRoute, + private fs: FileSystemService, + private remote: ElectronRemote, + private certificateService: CertificateService, + private router: Router) { + + this.data = this.certificateService.view(); + this.data.item.subscribe((certificate) => { + this.certificate = certificate; + if (certificate) { + this.decorator = new CertificateDecorator(certificate); + } + }); + this.data.deleted.subscribe((key) => { + if (this.certificateThumbprint === key) { + this.router.navigate(["/certificates"]); + } + }); + } + + public ngOnInit() { + this._paramsSubscriber = this.activatedRoute.params.subscribe((params) => { + this.certificateThumbprint = params["thumbprint"]; + this.data.params = { thumbprint: this.certificateThumbprint }; + this.data.fetch(); + }); + } + + public ngOnDestroy() { + this._paramsSubscriber.unsubscribe(); + this.data.dispose(); + } + + @autobind() + public refresh() { + return this.data.refresh(); + } + + @autobind() + public deleteCertificate() { + const config = new MatDialogConfig(); + const dialogRef = this.dialog.open(DeleteCertificateDialogComponent, config); + dialogRef.componentInstance.certificateThumbprint = this.certificate.thumbprint; + } + + @autobind() + public reactivateCertificate() { + const config = new MatDialogConfig(); + const dialogRef = this.dialog.open(ReactivateCertificateDialogComponent, config); + dialogRef.componentInstance.certificateThumbprint = this.certificate.thumbprint; + } + + @autobind() + public exportAsJSON() { + const dialog = this.remote.dialog; + const localPath = dialog.showSaveDialog({ + buttonLabel: "Export", + defaultPath: `${this.certificateThumbprint}.json`, + }); + + if (localPath) { + const content = JSON.stringify(this.certificate._original, null, 2); + return Observable.fromPromise(this.fs.saveFile(localPath, content)); + } + } +} diff --git a/app/components/certificate/details/certificate-details.html b/app/components/certificate/details/certificate-details.html new file mode 100644 index 0000000000..657de985ae --- /dev/null +++ b/app/components/certificate/details/certificate-details.html @@ -0,0 +1,26 @@ + +
+ +
{{decorator.thumbprint}}
+
+ {{decorator.state}} +
+ + + + + + + + +
+ + + + Configuration + + + + +
+
diff --git a/app/components/certificate/details/certificate-details.module.ts b/app/components/certificate/details/certificate-details.module.ts new file mode 100644 index 0000000000..7f266ac7e9 --- /dev/null +++ b/app/components/certificate/details/certificate-details.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from "@angular/core"; + +import { BaseModule } from "@batch-flask/ui"; +import { commonModules } from "app/common"; +import { CertificateConfigurationComponent } from "./certificate-configuration.component"; +import { CertificateDefaultComponent } from "./certificate-default.component"; +import { CertificateDetailsComponent } from "./certificate-details.component"; + +const components = [ + CertificateDetailsComponent, + CertificateDefaultComponent, + CertificateConfigurationComponent, +]; + +const modules = [ + BaseModule, +]; + +@NgModule({ + declarations: components, + exports: components, + imports: [ + ...commonModules, + ...modules, + ], +}) +export class CertificateDetailsModule { +} diff --git a/app/components/certificate/details/certificate-details.scss b/app/components/certificate/details/certificate-details.scss new file mode 100644 index 0000000000..cd067ef75b --- /dev/null +++ b/app/components/certificate/details/certificate-details.scss @@ -0,0 +1,4 @@ +@import "app/styles/variables"; + +bl-certificate-details { +} diff --git a/app/components/certificate/details/index.ts b/app/components/certificate/details/index.ts new file mode 100644 index 0000000000..c087cd78c8 --- /dev/null +++ b/app/components/certificate/details/index.ts @@ -0,0 +1,4 @@ +export * from "./certificate-default.component"; +export * from "./certificate-details.module"; +export * from "./certificate-details.component"; +export * from "./certificate-configuration.component"; diff --git a/app/components/certificate/home/certificate-home.component.ts b/app/components/certificate/home/certificate-home.component.ts new file mode 100644 index 0000000000..980d72f02d --- /dev/null +++ b/app/components/certificate/home/certificate-home.component.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { FormBuilder, FormControl } from "@angular/forms"; + +import { Filter, FilterBuilder, autobind } from "@batch-flask/core"; +import { SidebarManager } from "@batch-flask/ui/sidebar"; +import { CertificateCreateDialogComponent } from "../action/add"; + +@Component({ + selector: "bl-certificate-home", + templateUrl: "certificate-home.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CertificateHomeComponent { + public quickSearchQuery = new FormControl(); + + public filter: Filter = FilterBuilder.none(); + public quickFilter: Filter = FilterBuilder.none(); + public advancedFilter: Filter = FilterBuilder.none(); + + public keyField = "thumbprint"; + public config = { + quickSearchField: "thumbprint", + keyField: "thumbprint", + }; + + constructor(formBuilder: FormBuilder, private sidebarManager: SidebarManager) { + this.quickSearchQuery.valueChanges.debounceTime(400).distinctUntilChanged().subscribe((query: string) => { + if (query === "") { + this.quickFilter = FilterBuilder.none(); + } else { + this.quickFilter = FilterBuilder.prop("thumbprint").startswith(query); + } + + this._updateFilter(); + }); + } + + @autobind() + public addCertificate() { + this.sidebarManager.open("add-certificate", CertificateCreateDialogComponent); + } + + public advancedFilterChanged(filter: Filter) { + this.advancedFilter = filter; + this._updateFilter(); + } + + private _updateFilter() { + this.filter = FilterBuilder.and(this.quickFilter, this.advancedFilter); + } +} diff --git a/app/components/certificate/home/certificate-home.html b/app/components/certificate/home/certificate-home.html new file mode 100644 index 0000000000..7717604e5b --- /dev/null +++ b/app/components/certificate/home/certificate-home.html @@ -0,0 +1,12 @@ + +
+ Certificate +
+
+ + +
+ + + +
diff --git a/app/components/layout/pinned-entity-dropdown/pinned-dropdown.component.ts b/app/components/layout/pinned-entity-dropdown/pinned-dropdown.component.ts index c678f2ebd9..eb2ab455a7 100644 --- a/app/components/layout/pinned-entity-dropdown/pinned-dropdown.component.ts +++ b/app/components/layout/pinned-entity-dropdown/pinned-dropdown.component.ts @@ -58,6 +58,8 @@ export class PinnedDropDownComponent implements OnInit, OnDestroy { return "Batch job schedule"; case PinnedEntityType.Pool: return "Batch pool"; + case PinnedEntityType.Certificate: + return "Batch certificate"; case PinnedEntityType.FileGroup: return "File group"; default: @@ -75,6 +77,8 @@ export class PinnedDropDownComponent implements OnInit, OnDestroy { return "fa-calendar"; case PinnedEntityType.Pool: return "fa-database"; + case PinnedEntityType.Certificate: + return "fa-certificate"; case PinnedEntityType.FileGroup: return "fa-cloud-upload"; default: diff --git a/app/components/shared/main-navigation.html b/app/components/shared/main-navigation.html index 4dd99a708d..ca4c8a0753 100644 --- a/app/components/shared/main-navigation.html +++ b/app/components/shared/main-navigation.html @@ -24,6 +24,11 @@
Packages
+
  • + +
    Certificate
    +
    +
  • Data
    diff --git a/app/models/certificate.ts b/app/models/certificate.ts new file mode 100644 index 0000000000..647262660d --- /dev/null +++ b/app/models/certificate.ts @@ -0,0 +1,66 @@ +import { Model, NavigableRecord, Prop, Record } from "@batch-flask/core"; +import { NameValuePair } from "."; + +export interface DeleteCertificateError { + code: string; + message: string; + values: NameValuePair[]; +} + +export interface CertificateAttributes { + url: string; + thumbprint: string; + thumbprintAlgorithm: string; + publicData: string; + state: CertificateState; + stateTransitionTime: Date; + previousState: CertificateState; + previousStateTransitionTime: Date; + deleteCertificateError: DeleteCertificateError; +} +/** + * Class for displaying Batch certificate information. + */ +@Model() +export class Certificate extends Record implements NavigableRecord { + @Prop() public url: string; + @Prop() public thumbprint: string; + @Prop() public thumbprintAlgorithm: string; + @Prop() public publicData: string; + @Prop() public state: CertificateState; + @Prop() public stateTransitionTime: Date; + @Prop() public previousState: CertificateState; + @Prop() public previousStateTransitionTime: Date; + @Prop() public deleteCertificateError: DeleteCertificateError; + + /** + * If the certificate can be cancelled a failed deletion + * i.e. A active and deleting certificate cannot be reactived. + */ + public readonly reactivable: boolean; + + constructor(data: Partial = {}) { + super(data); + this.reactivable = this.state === CertificateState.deletefailed; + } + + // There is no id field in certificate, we need mock an id field for + // pin/unpin favorite function passing NavigableRecord + public get id(): string { + return this.thumbprint; + } + + public get name(): string { + return this.thumbprint; + } + + public get routerLink(): string[] { + return ["/certificates", this.thumbprint]; + } +} + +export enum CertificateState { + active = "active", + deletefailed = "deletefailed", + deleting = "deleting", +} diff --git a/app/models/decorators/certificate-decorator.ts b/app/models/decorators/certificate-decorator.ts new file mode 100644 index 0000000000..2a004dee6d --- /dev/null +++ b/app/models/decorators/certificate-decorator.ts @@ -0,0 +1,45 @@ +import { Certificate, CertificateState } from "app/models"; +import { DeleteCertificateErrorDecorator } from "app/models/decorators"; +import { DecoratorBase } from "app/utils/decorators"; + +export class CertificateDecorator extends DecoratorBase { + public state: string; + public stateTransitionTime: string; + public previousState: string; + public previousStateTransitionTime: string; + public stateIcon: string; + public thumbprintAlgorithm: string; + public thumbprint: string; + public publicData: string; + public url: string; + + public deleteCertificateError: DeleteCertificateErrorDecorator; + + constructor(certificate: Certificate) { + super(certificate); + + this.state = this.stateField(certificate.state); + this.stateTransitionTime = this.dateField(certificate.stateTransitionTime); + this.stateIcon = this._getStateIcon(certificate.state); + this.previousState = this.stateField(certificate.previousState); + this.previousStateTransitionTime = this.dateField(certificate.previousStateTransitionTime); + this.url = this.stringField(certificate.url); + this.thumbprintAlgorithm = this.stringField(certificate.thumbprintAlgorithm); + this.thumbprint = this.stringField(certificate.thumbprint); + this.publicData = this.stringField(certificate.publicData); + this.deleteCertificateError = new DeleteCertificateErrorDecorator( + certificate.deleteCertificateError || {} as any); + } + + private _getStateIcon(state: CertificateState): string { + switch (state) { + case CertificateState.active: + return "fa-cog"; + case CertificateState.deleting: + case CertificateState.deletefailed: + return "fa-ban"; + default: + return "fa-question-circle-o"; + } + } +} diff --git a/app/models/decorators/delete-certificate-error-decorator.ts b/app/models/decorators/delete-certificate-error-decorator.ts new file mode 100644 index 0000000000..1684c110e5 --- /dev/null +++ b/app/models/decorators/delete-certificate-error-decorator.ts @@ -0,0 +1,41 @@ +import { DeleteCertificateError, NameValuePair } from "app/models"; +import { DecoratorBase } from "app/utils/decorators"; + +export class DeleteCertificateErrorDecorator extends DecoratorBase { + public code: string; + public message: string; + public values: NameValuePair[]; + public details: string; + + /** + * Combination of the error in a small summary to be displayed in grid for example + */ + public summary: string; + + /** + * Flag to know if the error exists + */ + public exists: boolean; + + constructor(error: DeleteCertificateError) { + super(error); + + this.exists = Boolean(error); + this.code = this.stringField(error.code); + this.message = this.stringField(error.message); + this.values = error.values; + this.details = this._getDetails(error.values); + this.summary = this.exists + ? `code: ${error.code}, message: ${error.message}` + : ""; + } + + private _getDetails(values: NameValuePair[]): string { + if (values && values.length > 0) { + return values.map(value => { + return `${value.name}: ${value.value}`; + }).join("
    "); } + + return ""; + } +} diff --git a/app/models/decorators/index.ts b/app/models/decorators/index.ts index 3804c6d1e9..1be4332185 100644 --- a/app/models/decorators/index.ts +++ b/app/models/decorators/index.ts @@ -4,6 +4,8 @@ export * from "./compute-node-info-decorator"; export * from "./container-configuration-decorator"; export * from "./container-decorator"; export * from "./container-lease-decorator"; +export * from "./certificate-decorator"; +export * from "./delete-certificate-error-decorator"; export * from "./failure-info-decorator"; export * from "./image-reference-decorator"; export * from "./job-constraints-decorator"; diff --git a/app/models/index.ts b/app/models/index.ts index 21c588395f..a5d291a96b 100644 --- a/app/models/index.ts +++ b/app/models/index.ts @@ -14,6 +14,7 @@ export * from "./auto-user"; export * from "./autoscale-formula"; export * from "./batch-quotas"; export * from "./blob-container"; +export * from "./certificate"; export * from "./certificate-reference"; export * from "./cloud-service-configuration"; export * from "./compute-node-error"; diff --git a/app/services/batch-api/batch-client-proxy.ts b/app/services/batch-api/batch-client-proxy.ts index 7f317d434b..d7213d6c02 100644 --- a/app/services/batch-api/batch-client-proxy.ts +++ b/app/services/batch-api/batch-client-proxy.ts @@ -1,6 +1,7 @@ import { BatchServiceClient } from "azure-batch"; import AccountProxy from "./accountProxy"; +import { CertificateProxy } from "./certificateProxy"; import { FileProxy } from "./fileProxy"; import { JobProxy } from "./jobProxy"; import { JobScheduleProxy } from "./jobScheduleProxy"; @@ -22,6 +23,7 @@ export class BatchClientProxy { private _job: JobProxy; private _jobSchedule: JobScheduleProxy; private _pool: PoolProxy; + private _certificate: CertificateProxy; private _task: TaskProxy; private _node: NodeProxy; @@ -33,6 +35,7 @@ export class BatchClientProxy { this._job = new JobProxy(this.client); this._jobSchedule = new JobScheduleProxy(this.client); this._pool = new PoolProxy(this.client); + this._certificate = new CertificateProxy(this.client); this._task = new TaskProxy(this.client); this._node = new NodeProxy(this.client); } @@ -61,6 +64,10 @@ export class BatchClientProxy { return this.checkProxy(this._pool); } + get certificate(): CertificateProxy { + return this.checkProxy(this._certificate); + } + get node(): NodeProxy { return this.checkProxy(this._node); } diff --git a/app/services/batch-api/certificateProxy.ts b/app/services/batch-api/certificateProxy.ts new file mode 100644 index 0000000000..187483b4eb --- /dev/null +++ b/app/services/batch-api/certificateProxy.ts @@ -0,0 +1,64 @@ +import { BatchServiceClient, BatchServiceModels } from "azure-batch"; + +import { ListProxy, mapGet, wrapOptions } from "./shared"; + +export class CertificateProxy { + + constructor(private client: BatchServiceClient) { + } + + /** + * Adds a certificate to the specified account. + * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/CertificateOperations.html#add + * @param certificate: The certificate object + * @param options: Optional Parameters. + */ + public add(certificate: any, options?: any): Promise { + return this.client.certificate.add(certificate, wrapOptions(options)); + } + + /** + * Deletes a certificate from the specified account. + * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/CertificateOperations.html#deleteMethod + * @param thumbprintAlgorithm: The algorithm used to derive the thumbprint parameter. This must be sha1. + * @param thumbprint: The thumbprint of the certificate to be deleted. + * @param options: Optional Parameters. + */ + public delete(thumbprintAlgorithm: string, thumbprint: string, options?: any): Promise { + return this.client.certificate.deleteMethod(thumbprintAlgorithm, thumbprint, wrapOptions(options)); + } + + /** + * Cancels a failed deletion of a certificate from the specified account. + * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/CertificateOperations.html#cancelDeletion + * @param thumbprintAlgorithm: The algorithm used to derive the thumbprint parameter. This must be sha1. + * @param thumbprint: The thumbprint of the certificate being deleted + * @param options: Optional Parameters. + */ + public cancelDeletion(thumbprintAlgorithm: string, thumbprint: string, options?: any): Promise { + return this.client.certificate.cancelDeletion(thumbprintAlgorithm, thumbprint, wrapOptions(options)); + } + + /** + * Gets information about the specified certificate. + * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/CertificateOperations.html#get + * @param thumbprintAlgorithm: The algorithm used to derive the thumbprint parameter. This must be sha1. + * @param thumbprint: The thumbprint of the certificate to get. + * @param options: Optional Parameters. + */ + public get(thumbprintAlgorithm: string, thumbprint: string, options?: BatchServiceModels.CertificateGetOptions) + : Promise { + return mapGet(this.client.certificate.get(thumbprintAlgorithm, thumbprint, wrapOptions({ + certificateGetOptions: options, + }))); + } + + /** + * Lists all of the certificates that have been added to the specified + * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/CertificateOperations.html#list + * @param options: Optional Parameters. + */ + public list(options?: BatchServiceModels.CertificateListOptions) { + return new ListProxy(this.client.certificate, null, wrapOptions({ certificateListOptions: options })); + } +} diff --git a/app/services/batch-api/jobScheduleProxy.ts b/app/services/batch-api/jobScheduleProxy.ts index 8d8f525270..c08c9624af 100644 --- a/app/services/batch-api/jobScheduleProxy.ts +++ b/app/services/batch-api/jobScheduleProxy.ts @@ -63,7 +63,7 @@ export class JobScheduleProxy { * @param options: Optional Parameters. */ public list(options?: BatchServiceModels.JobScheduleListOptions) { - return new ListProxy(this.client.jobSchedule, null, wrapOptions({ jobListOptions: options })); + return new ListProxy(this.client.jobSchedule, null, wrapOptions({ jobScheduleListOptions: options })); } /** diff --git a/app/services/certificate.service.ts b/app/services/certificate.service.ts new file mode 100644 index 0000000000..356b386ec2 --- /dev/null +++ b/app/services/certificate.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from "@angular/core"; +import { List } from "immutable"; +import * as forge from "node-forge"; +import { AsyncSubject, Observable, Subject } from "rxjs"; + +import { Certificate } from "app/models"; +import { Constants, FileUrlUtils, log } from "app/utils"; +import { BatchClientService } from "./batch-client.service"; +import { + BatchEntityGetter, BatchListGetter, ContinuationToken, + DataCache, EntityView, ListOptionsAttributes, ListView, +} from "./core"; +import { ServiceBase } from "./service-base"; + +export interface CertificateListParams { +} + +export interface CertificateParams { + thumbprint?: string; + thumbprintAlgorithm?: string; +} + +export interface CertificateListOptions extends ListOptionsAttributes { +} + +export const defaultThumbprintAlgorithm = "sha1"; + +export enum CertificateFormat { + pfx = "pfx", + cer = "cer", +} + +@Injectable() +export class CertificateService extends ServiceBase { + /** + * Triggered only when a certificate is added through this app. + * Used to notify the list of a new item + */ + public onCertificateAdded = new Subject(); + public cache = new DataCache(); + + private _basicProperties: string = "thumbprint,thumbprintAlgorithm,state,stateTransitionTime"; + private _getter: BatchEntityGetter; + private _listGetter: BatchListGetter; + + constructor(batchService: BatchClientService) { + super(batchService); + + this._getter = new BatchEntityGetter(Certificate, this.batchService, { + cache: () => this.cache, + getFn: (client, params: CertificateParams) => { + const algorithm = params.thumbprintAlgorithm || defaultThumbprintAlgorithm; + return client.certificate.get(algorithm, params.thumbprint); + }, + }); + + this._listGetter = new BatchListGetter(Certificate, this.batchService, { + cache: () => this.cache, + list: (client, params: CertificateListParams, options) => { + return client.certificate.list({ certificateListOptions: options }); + }, + listNext: (client, nextLink: string) => client.certificate.listNext(nextLink), + }); + } + + public get basicProperties(): string { + return this._basicProperties; + } + + public list(options?: any, forceNew?: boolean); + public list(nextLink: ContinuationToken); + public list(nextLinkOrOptions: any, options = {}, forceNew = false) { + if (nextLinkOrOptions.nextLink) { + return this._listGetter.fetch(nextLinkOrOptions); + } else { + return this._listGetter.fetch({}, options, forceNew); + } + } + + public listView(options: ListOptionsAttributes = {}): ListView { + return new ListView({ + cache: () => this.cache, + getter: this._listGetter, + initialOptions: options, + }); + } + + public listAll(options: CertificateListOptions = {}): Observable> { + return this._listGetter.fetchAll({}, options); + } + + public get(thumbprint: string, options: any = {}, thumbprintAlgorithm?: string): Observable { + const algorithm = thumbprintAlgorithm || defaultThumbprintAlgorithm; + return this._getter.fetch({ thumbprintAlgorithm: algorithm, thumbprint: thumbprint }); + } + + /** + * Create an entity view for a certificate + */ + public view(): EntityView { + return new EntityView({ + cache: () => this.cache, + getter: this._getter, + poll: Constants.PollRate.entity, + }); + } + + public delete(thumbprint: string, options: any = {}, thumbprintAlgorithm?: string): Observable<{}> { + return this.callBatchClient((client) => { + const algorithm = thumbprintAlgorithm || defaultThumbprintAlgorithm; + return client.certificate.delete(algorithm, thumbprint, options); + }, (error) => { + log.error(`Error cancel delete certificate: ${thumbprint}`, error); + }); + } + + public add(certificate: any, options: any = {}): Observable<{}> { + return this.callBatchClient((client) => client.certificate.add(certificate, options)); + } + + public cancelDelete(thumbprint: string, options: any = {}, thumbprintAlgorithm?: string) { + return this.callBatchClient((client) => { + const algorithm = thumbprintAlgorithm || defaultThumbprintAlgorithm; + return client.certificate.cancelDeletion(algorithm, thumbprint, options); + }, (error) => { + log.error(`Error cancel delete certificate: ${thumbprint}`, error); + }); + } + + /** + * parseCertificate helps to parse uploaded certificate and return parameters which can be + * used to create batch certificate + * @param file uploaded certificate + * @param password password for pfx certifcate + */ + public parseCertificate(file: File, password: string) { + const subject = new AsyncSubject(); + const reader = new FileReader(); + reader.readAsBinaryString(file); + reader.onload = () => { + // try catch potential error when get thumbprint + try { + const certificateFormat = FileUrlUtils.getFileExtension(file.name); + const binaryEncodedData = reader.result; + const base64EncodedData = btoa(binaryEncodedData); + const isCer = certificateFormat === CertificateFormat.cer; + const data = isCer ? binaryEncodedData : base64EncodedData; + const thumbprint = this._generateThumbprint(data, + CertificateFormat[certificateFormat], password); + subject.next({ + thumbprintAlgorithm: defaultThumbprintAlgorithm, + thumbprint: thumbprint, + data: base64EncodedData, + certificateFormat: certificateFormat, + password: !isCer ? password : null, + }); + subject.complete(); + } catch (err) { + subject.error(err); + } + }; + reader.onerror = () => { + subject.error("Error encountered reading certificate file as binary string."); + }; + return subject.asObservable(); + } + + /** + * This function is a helper function for generating certificate thumbprint based on input + * data, certificate format and password. Now only support pfx and cer certificate thumbprint generation + * @param data binary string of uploaded file + * @param format certificate type. Ex. cer or pfx + * @param password only specify password when format is pfx + */ + private _generateThumbprint(data: string, format: CertificateFormat, password?: string): string { + let certDer: string = null; + switch (format) { + case CertificateFormat.pfx: + const p12Der = forge.util.decode64(data); + const p12Asn1 = forge.asn1.fromDer(p12Der); + const outCert = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, password); + const keyBags = outCert.getBags({ bagType: forge.pki.oids.certBag }); + const bag = keyBags[forge.pki.oids.certBag][0]; + const certAsn1 = forge.pki.certificateToAsn1(bag.cert); + certDer = forge.asn1.toDer(certAsn1).getBytes(); + break; + case CertificateFormat.cer: + const outAsn1 = forge.asn1.fromDer(data); + certDer = forge.asn1.toDer(outAsn1).getBytes(); + break; + default: + throw new Error(`Supported certificate type are CER and PFX, + current certificate type is not supported.`); + } + const md = forge.md.sha1.create(); + md.start(); + md.update(certDer); + const digest = md.digest(); + return digest.toHex(); + } +} diff --git a/app/services/index.ts b/app/services/index.ts index 1be5535278..4dd02ffd42 100644 --- a/app/services/index.ts +++ b/app/services/index.ts @@ -7,6 +7,7 @@ export * from "./azure-http.service"; export * from "./arm-http.service"; export * from "./batch-labs.service"; export * from "./cache-data.service"; +export * from "./certificate.service"; export * from "./compute.service"; export * from "./file-service"; export * from "./fs.service"; diff --git a/app/services/pinned-entity.service.ts b/app/services/pinned-entity.service.ts index 89e9be80b3..8084300f60 100644 --- a/app/services/pinned-entity.service.ts +++ b/app/services/pinned-entity.service.ts @@ -4,7 +4,7 @@ import { AsyncSubject, BehaviorSubject, Observable } from "rxjs"; import { NavigableRecord, PinnableEntity, PinnedEntityType } from "@batch-flask/core"; import { - BatchApplication, BlobContainer, Job, JobSchedule, Pool, + BatchApplication, BlobContainer, Certificate, Job, JobSchedule, Pool, } from "app/models"; import { AccountService } from "./account.service"; import { LocalFileStorage } from "./local-file-storage.service"; @@ -14,6 +14,7 @@ pinnedTypeMap.set(PinnedEntityType.Application, BatchApplication); pinnedTypeMap.set(PinnedEntityType.Pool, Pool); pinnedTypeMap.set(PinnedEntityType.Job, Job); pinnedTypeMap.set(PinnedEntityType.JobSchedule, JobSchedule); +pinnedTypeMap.set(PinnedEntityType.Certificate, Certificate); pinnedTypeMap.set(PinnedEntityType.FileGroup, BlobContainer); @Injectable() diff --git a/package.json b/package.json index b21458ad19..33c8c01bf7 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "mousetrap": "^1.6.0", "ms-rest-js": "~0.2.3", "node-fetch": "~1.7.3", + "node-forge": "~0.7.4", "reflect-metadata": "^0.1.12", "rxjs": "~5.5.6", "strip-json-comments": "~2.0.1", diff --git a/src/@batch-flask/core/record/navigable-record.ts b/src/@batch-flask/core/record/navigable-record.ts index e70c0f38c8..fe3ee7d1d6 100644 --- a/src/@batch-flask/core/record/navigable-record.ts +++ b/src/@batch-flask/core/record/navigable-record.ts @@ -3,6 +3,7 @@ export enum PinnedEntityType { Job, JobSchedule, Pool, + Certificate, FileGroup, } diff --git a/src/@batch-flask/ui/browse-layout/browse-layout.html b/src/@batch-flask/ui/browse-layout/browse-layout.html index de31fd40bc..2c2e1d4c48 100644 --- a/src/@batch-flask/ui/browse-layout/browse-layout.html +++ b/src/@batch-flask/ui/browse-layout/browse-layout.html @@ -38,7 +38,7 @@
    -
    +
    diff --git a/src/@batch-flask/ui/property-list/property-content/property-content.scss b/src/@batch-flask/ui/property-list/property-content/property-content.scss index bd2e4d8c6d..71412f9c44 100644 --- a/src/@batch-flask/ui/property-list/property-content/property-content.scss +++ b/src/@batch-flask/ui/property-list/property-content/property-content.scss @@ -15,6 +15,10 @@ bl-property-content { white-space: nowrap; } + &.wrap { + word-wrap: break-word; + } + .fa { font-size: 16px !important; } diff --git a/src/common/constants/constants.ts b/src/common/constants/constants.ts index 2ae3c8a6d6..747a80f699 100644 --- a/src/common/constants/constants.ts +++ b/src/common/constants/constants.ts @@ -39,6 +39,7 @@ export const forms = { id: /^[\w\_-]+$/i, appVersion: /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/i, appFilename: /\.zip$/i, + certificateFileName: /(\.pfx|\.cer)$/i, fileGroup: /^[a-z0-9]([a-z0-9]|-(?!-|\z))*$/, batchAccount: /^[0-9a-z]*$/, }, diff --git a/yarn.lock b/yarn.lock index b51b990a98..74f033b71e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6196,6 +6196,10 @@ node-forge@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300" +node-forge@~0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.4.tgz#8e6e9f563a1e32213aa7508cded22aa791dbf986" + node-gyp@^3.3.1: version "3.6.2" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.2.tgz#9bfbe54562286284838e750eac05295853fa1c60"