diff --git a/apps/web/src/app/billing/individual/premium/premium-v2.component.html b/apps/web/src/app/billing/individual/premium/premium-v2.component.html index bdf6ff87d1..eb93d8ef6a 100644 --- a/apps/web/src/app/billing/individual/premium/premium-v2.component.html +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.html @@ -66,33 +66,39 @@ -

{{ "uploadLicenseFilePremium" | i18n }}

-
- - {{ "licenseFile" | i18n }} -
- - {{ - licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n) - }} -
- - {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} -
- -
+ +

{{ "uploadLicenseFilePremium" | i18n }}

+
+ + {{ "licenseFile" | i18n }} +
+ + {{ + licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n) + }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} +
+ +
+
+
diff --git a/apps/web/src/app/billing/individual/premium/premium-v2.component.ts b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts index cf66dac2f7..4f6d30530c 100644 --- a/apps/web/src/app/billing/individual/premium/premium-v2.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts @@ -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 => { @@ -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 { + await this.postFinalizeUpgrade(); + } } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 5992af5f54..e6e2610d67 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -7,32 +7,38 @@ {{ "loading" | i18n }} -

{{ "uploadLicenseFileOrg" | i18n }}

- - - {{ "licenseFile" | i18n }} -
- - {{ selectedFile?.name ?? ("noFileChosen" | i18n) }} -
- - {{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }} -
- - + +

{{ "uploadLicenseFileOrg" | i18n }}

+
+ + {{ "licenseFile" | i18n }} +
+ + {{ selectedFile?.name ?? ("noFileChosen" | i18n) }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }} +
+ +
+
+
{ + 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 }); + } } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index b966729c1d..57491a73e6 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -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 {} diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/abstract-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/abstract-self-hosting-license-uploader.component.ts new file mode 100644 index 0000000000..01605eae7d --- /dev/null +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/abstract-self-hosting-license-uploader.component.ts @@ -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 { + 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; +} diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts new file mode 100644 index 0000000000..0d7698e00f --- /dev/null +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/individual-self-hosting-license-uploader.component.ts @@ -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 = new EventEmitter(); + + 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 { + 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"; + } +} diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/license-uploader-form-model.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/license-uploader-form-model.ts new file mode 100644 index 0000000000..42ce1be2ea --- /dev/null +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/license-uploader-form-model.ts @@ -0,0 +1,3 @@ +export interface LicenseUploaderFormModel { + file: File; +} diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts new file mode 100644 index 0000000000..79c3cb83a4 --- /dev/null +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts @@ -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 = new EventEmitter(); + + 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 { + await super.submit(); + + const orgKey = await this.cryptoService.makeOrgKey(); + 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"; + } +} diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/self-hosting-license-uploader.component.html b/apps/web/src/app/billing/shared/self-hosting-license-uploader/self-hosting-license-uploader.component.html new file mode 100644 index 0000000000..2238c10dd4 --- /dev/null +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/self-hosting-license-uploader.component.html @@ -0,0 +1,26 @@ +

{{ "uploadLicenseFileOrg" | i18n }}

+ + + {{ description | i18n }} +
+ + {{ form.value.file ? form.value.file.name : ("noFileChosen" | i18n) }} +
+ + {{ "licenseFileDesc" | i18n: hintFileName }} +
+ + diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7ac473ff6a..505fe33e82 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;