[PM-11901] Refactoring self-hosting license file uploader (#11083)

This commit is contained in:
Jonas Hendrickx 2024-09-26 11:23:23 +02:00 committed by GitHub
parent 8fb97e7b60
commit d2e5af7fb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 374 additions and 53 deletions

View File

@ -66,33 +66,39 @@
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
<ng-container *ngIf="!(useLicenseUploaderComponent$ | async)">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
</ng-container>
<individual-self-hosting-license-uploader
*ngIf="useLicenseUploaderComponent$ | async"
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<bit-section>

View File

@ -7,6 +7,8 @@ import { combineLatest, concatMap, from, Observable, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -36,6 +38,10 @@ export class PremiumV2Component {
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected useLicenseUploaderComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader,
);
protected readonly familyPlanMaxUserCount = 6;
protected readonly premiumPrice = 10;
protected readonly storageGBPrice = 4;
@ -44,6 +50,7 @@ export class PremiumV2Component {
private activatedRoute: ActivatedRoute,
private apiService: ApiService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@ -78,6 +85,9 @@ export class PremiumV2Component {
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
postFinalizeUpgrade = async () => {
this.toastService.showToast({
variant: "success",
title: null,
@ -119,6 +129,7 @@ export class PremiumV2Component {
await this.apiService.postAccountLicense(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
submitPayment = async (): Promise<void> => {
@ -138,6 +149,7 @@ export class PremiumV2Component {
await this.apiService.postPremium(formData);
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get additionalStorageCost(): number {
@ -161,4 +173,8 @@ export class PremiumV2Component {
protected get total(): number {
return this.subtotal + this.estimatedTax;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
}

View File

@ -7,32 +7,38 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="createOrganization && selfHosted">
<p bitTypography="body1">{{ "uploadLicenseFileOrg" | i18n }}</p>
<form [formGroup]="selfHostedForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ selectedFile?.name ?? ("noFileChosen" | i18n) }}
</div>
<input
#fileSelector
bitInput
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
accept="application/JSON"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>
<ng-container *ngIf="!(useLicenseUploaderComponent$ | async)">
<p bitTypography="body1">{{ "uploadLicenseFileOrg" | i18n }}</p>
<form [formGroup]="selfHostedForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ selectedFile?.name ?? ("noFileChosen" | i18n) }}
</div>
<input
#fileSelector
bitInput
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
accept="application/JSON"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>
</ng-container>
<organization-self-hosting-license-uploader
*ngIf="useLicenseUploaderComponent$ | async"
(onLicenseFileUploaded)="onLicenseFileUploaded($event)"
/>
</ng-container>
<form
[formGroup]="formGroup"

View File

@ -117,6 +117,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
discount = 0;
deprecateStripeSourcesAPI: boolean;
protected useLicenseUploaderComponent$ = this.configService.getFeatureFlag$(
FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader,
);
secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder);
selfHostedForm = this.formBuilder.group({
@ -855,4 +859,30 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private planIsEnabled(plan: PlanResponse) {
return !plan.disabled && !plan.legacyYear;
}
protected async onLicenseFileUploaded(organizationId: string): Promise<void> {
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("organizationCreated"),
message: this.i18nService.t("organizationReadyToGo"),
});
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/organizations/" + organizationId]);
}
if (this.isInTrialFlow) {
this.onTrialBillingSuccess.emit({
orgId: organizationId,
subLabelText: this.billingSubLabelText(),
});
}
this.onSuccess.emit({ organizationId: organizationId });
// TODO: No one actually listening to this message?
this.messagingService.send("organizationCreated", { organizationId: organizationId });
}
}

View File

@ -13,6 +13,8 @@ import { OffboardingSurveyComponent } from "./offboarding-survey.component";
import { PaymentV2Component } from "./payment/payment-v2.component";
import { PaymentComponent } from "./payment/payment.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
import { TaxInfoComponent } from "./tax-info.component";
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
@ -40,6 +42,8 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
OffboardingSurveyComponent,
AdjustPaymentDialogV2Component,
AdjustStorageDialogV2Component,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
],
exports: [
SharedModule,
@ -53,6 +57,8 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
OffboardingSurveyComponent,
VerifyBankAccountComponent,
PaymentV2Component,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
],
})
export class BillingSharedModule {}

View File

@ -0,0 +1,81 @@
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { LicenseUploaderFormModel } from "./license-uploader-form-model";
/**
* Shared implementation for processing license file uploads.
* @remarks Requires self-hosting.
*/
export abstract class AbstractSelfHostingLicenseUploaderComponent {
protected form: FormGroup;
protected constructor(
protected readonly formBuilder: FormBuilder,
protected readonly i18nService: I18nService,
protected readonly platformUtilsService: PlatformUtilsService,
protected readonly toastService: ToastService,
protected readonly tokenService: TokenService,
) {
const isSelfHosted = this.platformUtilsService.isSelfHost();
if (!isSelfHosted) {
throw new Error("This component should only be used in self-hosted environments");
}
this.form = this.formBuilder.group({
file: [null, [Validators.required]],
});
this.submit = this.submit.bind(this);
}
/**
* Gets the submitted license upload form model.
* @protected
*/
protected get formValue(): LicenseUploaderFormModel {
return this.form.value as LicenseUploaderFormModel;
}
/**
* Triggered when a different license file is selected.
* @param event
*/
onLicenseFileSelectedChanged(event: Event): void {
const element = event.target as HTMLInputElement;
this.form.value.file = element.files.length > 0 ? element.files[0] : null;
}
/**
* Submits the license upload form.
* @protected
*/
protected async submit(): Promise<void> {
this.form.markAllAsTouched();
if (this.form.invalid) {
return this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectFile"),
});
}
const emailVerified = await this.tokenService.getEmailVerified();
if (!emailVerified) {
return this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("verifyEmailFirst"),
});
}
}
abstract get description(): string;
abstract get hintFileName(): string;
}

View File

@ -0,0 +1,60 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { AbstractSelfHostingLicenseUploaderComponent } from "../../shared/self-hosting-license-uploader/abstract-self-hosting-license-uploader.component";
/**
* Processes license file uploads for individual plans.
* @remarks Requires self-hosting.
*/
@Component({
selector: "individual-self-hosting-license-uploader",
templateUrl: "./self-hosting-license-uploader.component.html",
})
export class IndividualSelfHostingLicenseUploaderComponent extends AbstractSelfHostingLicenseUploaderComponent {
/**
* Emitted when a license file has been successfully uploaded & processed.
*/
@Output() onLicenseFileUploaded: EventEmitter<void> = new EventEmitter<void>();
constructor(
protected readonly apiService: ApiService,
protected readonly formBuilder: FormBuilder,
protected readonly i18nService: I18nService,
protected readonly platformUtilsService: PlatformUtilsService,
protected readonly syncService: SyncService,
protected readonly toastService: ToastService,
protected readonly tokenService: TokenService,
) {
super(formBuilder, i18nService, platformUtilsService, toastService, tokenService);
}
protected async submit(): Promise<void> {
await super.submit();
const formData = new FormData();
formData.append("license", this.formValue.file);
await this.apiService.postAccountLicense(formData);
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
this.onLicenseFileUploaded.emit();
}
get description(): string {
return "uploadLicenseFilePremium";
}
get hintFileName(): string {
return "bitwarden_premium_license.json";
}
}

View File

@ -0,0 +1,3 @@
export interface LicenseUploaderFormModel {
file: File;
}

View File

@ -0,0 +1,85 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrgKey } from "@bitwarden/common/types/key";
import { ToastService } from "@bitwarden/components";
import { AbstractSelfHostingLicenseUploaderComponent } from "../../shared/self-hosting-license-uploader/abstract-self-hosting-license-uploader.component";
/**
* Processes license file uploads for organizations.
* @remarks Requires self-hosting.
*/
@Component({
selector: "organization-self-hosting-license-uploader",
templateUrl: "./self-hosting-license-uploader.component.html",
})
export class OrganizationSelfHostingLicenseUploaderComponent extends AbstractSelfHostingLicenseUploaderComponent {
/**
* Notifies the parent component of the `organizationId` the license was created for.
*/
@Output() onLicenseFileUploaded: EventEmitter<string> = new EventEmitter<string>();
constructor(
protected readonly formBuilder: FormBuilder,
protected readonly i18nService: I18nService,
protected readonly platformUtilsService: PlatformUtilsService,
protected readonly toastService: ToastService,
protected readonly tokenService: TokenService,
private readonly apiService: ApiService,
private readonly encryptService: EncryptService,
private readonly cryptoService: CryptoService,
private readonly organizationApiService: OrganizationApiServiceAbstraction,
private readonly syncService: SyncService,
) {
super(formBuilder, i18nService, platformUtilsService, toastService, tokenService);
}
protected async submit(): Promise<void> {
await super.submit();
const orgKey = await this.cryptoService.makeOrgKey<OrgKey>();
const key = orgKey[0].encryptedString;
const collection = await this.encryptService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey[1],
);
const collectionCt = collection.encryptedString;
const orgKeys = await this.cryptoService.makeKeyPair(orgKey[1]);
const fd = new FormData();
fd.append("license", this.formValue.file);
fd.append("key", key);
fd.append("collectionName", collectionCt);
const response = await this.organizationApiService.createLicense(fd);
const orgId = response.id;
await this.apiService.refreshIdentityToken();
// Org Keys live outside of the OrganizationLicense - add the keys to the org here
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
await this.organizationApiService.updateKeys(orgId, request);
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
this.onLicenseFileUploaded.emit(orgId);
}
get description(): string {
return "uploadLicenseFileOrg";
}
get hintFileName(): string {
return "bitwarden_organization_license.json";
}
}

View File

@ -0,0 +1,26 @@
<p bitTypography="body1">{{ "uploadLicenseFileOrg" | i18n }}</p>
<form [formGroup]="form" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ description | i18n }}</bit-label>
<div>
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ form.value.file ? form.value.file.name : ("noFileChosen" | i18n) }}
</div>
<input
#fileSelector
bitInput
type="file"
formControlName="file"
(change)="onLicenseFileSelectedChanged($event)"
accept="application/JSON"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: hintFileName }}</bit-hint>
</bit-form-field>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>

View File

@ -33,6 +33,7 @@ export enum FeatureFlag {
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -76,6 +77,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;